From 69243141ddc0f7db169cc115804abe51454eacaa Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 18 Mar 2026 01:02:21 -0600 Subject: [PATCH] Add project taskitem pool and planner fixes --- .../AGENT_HANDOFF_2026-03-18_taskitem_pool.md | 69 +++ editor/src/AgentPermissionPolicy.h | 7 +- editor/src/ArchitectIntakeProcessor.h | 218 ++++++++ editor/src/HeadlessAgentRPCHandler.h | 1 + editor/src/MCPServer.h | 29 +- editor/src/RequirementsParser.h | 103 +++- editor/src/ScopeMilestoneDecomposer.h | 228 +++++++- editor/src/SelfContainmentScorer.h | 29 +- editor/src/TaskitemPoolPersistence.h | 352 +++++++++++++ editor/src/headless_rpc/DispatchPart5.h | 300 +++++++++++ editor/src/mcp/RegisterArchitectIntakeTools.h | 497 ++++++++++++++++-- .../src/mcp/RegisterOnboardingAndAllTools.h | 1 + editor/src/mcp/RegisterTaskitemPoolTools.h | 72 +++ editor/src/mcp/RegisterValidationTools.h | 25 +- tools/mcp/run_sprint_taskitem_pipeline.sh | 20 +- 15 files changed, 1860 insertions(+), 91 deletions(-) create mode 100644 docs/AGENT_HANDOFF_2026-03-18_taskitem_pool.md create mode 100644 editor/src/ArchitectIntakeProcessor.h create mode 100644 editor/src/TaskitemPoolPersistence.h create mode 100644 editor/src/mcp/RegisterTaskitemPoolTools.h diff --git a/docs/AGENT_HANDOFF_2026-03-18_taskitem_pool.md b/docs/AGENT_HANDOFF_2026-03-18_taskitem_pool.md new file mode 100644 index 0000000..385871d --- /dev/null +++ b/docs/AGENT_HANDOFF_2026-03-18_taskitem_pool.md @@ -0,0 +1,69 @@ +# Agent Handoff 2026-03-18 + +## Summary + +This handoff covers two related pieces of work: + +1. The Whetstone planning pipeline for `whimpwm` was debugged and improved so the `sprint_001_execution_spec_whetstone.md` run now produces a usable queue. +2. A project-scoped taskitem pool was added so external agents can claim/release/complete generated taskitems directly from the target project without relying on an always-running orchestrator. + +## Key Outcomes + +- Fixed the spec-to-taskitem path so prose sections are preserved and mapped into better normalized requirements. +- Fixed workspace-aware target inference so generated taskitems no longer drift into Whetstone internals when planning for another project. +- Added a project-side taskitem pool persisted under `.whetstone/.taskitem_pool.json`. +- Added MCP tools: + - `whetstone_import_taskitem_pool` + - `whetstone_list_taskitem_pool` + - `whetstone_claim_taskitem` + - `whetstone_release_taskitem` + - `whetstone_complete_pooled_taskitem` +- Added lock-based contention safety for the pool using a `.lock` sidecar plus atomic temp-file writes. +- Fixed MCP error shaping so failed claims now surface as proper tool errors with `isError: true`. + +## Files Changed + +- `editor/src/ArchitectIntakeProcessor.h` +- `editor/src/RequirementsParser.h` +- `editor/src/ScopeMilestoneDecomposer.h` +- `editor/src/SelfContainmentScorer.h` +- `editor/src/mcp/RegisterArchitectIntakeTools.h` +- `editor/src/mcp/RegisterValidationTools.h` +- `tools/mcp/run_sprint_taskitem_pipeline.sh` +- `editor/src/TaskitemPoolPersistence.h` +- `editor/src/mcp/RegisterTaskitemPoolTools.h` +- `editor/src/AgentPermissionPolicy.h` +- `editor/src/HeadlessAgentRPCHandler.h` +- `editor/src/headless_rpc/DispatchPart5.h` +- `editor/src/MCPServer.h` +- `editor/src/mcp/RegisterOnboardingAndAllTools.h` + +## Verified Behavior + +- `whetstone_mcp` rebuild succeeds. +- `whimpwm` queue import succeeds from: + - `logs/taskitem_runs/sprint_001_execution_spec_whetstone_20260317_214642/02_generate_taskitems.json` +- Project pool persists at: + - `/home/bill/Documents/whimpwm/.whetstone/whimpwm.taskitem_pool.json` +- Concurrency test succeeded: + - two workers racing to claim the same task resulted in exactly one claim winner + - loser returned `Taskitem not ready` with `isError: true` + +## Current `whimpwm` State + +- The `whimpwm` pool was reset to a clean imported state before handoff. +- Current counts: + - `ready: 6` + - `blocked: 1` + - `total: 7` +- No task is currently claimed. + +## Recommended Next Steps + +1. Add a lightweight MCP/import bridge from planner runs into workflow items if you still want parity between the legacy workflow system and the project-scoped pool. +2. Decide whether `.whetstone/*.taskitem_pool.json` should remain runtime-only or become a versioned artifact for some projects. +3. Add explicit tests for: + - import/claim/release/complete lifecycle + - concurrent claim contention + - error-shaping contract on failed pool mutations +4. Start real worker execution against the `whimpwm` queue now that the pool path is usable. diff --git a/editor/src/AgentPermissionPolicy.h b/editor/src/AgentPermissionPolicy.h index a8063a5..3a752ef 100644 --- a/editor/src/AgentPermissionPolicy.h +++ b/editor/src/AgentPermissionPolicy.h @@ -79,7 +79,8 @@ struct AgentPermissionPolicy { method == "getBlockers" || method == "getProgress" || method == "getEventStream" || - method == "getRecentEvents") { + method == "getRecentEvents" || + method == "listTaskitemPool") { return true; } @@ -114,6 +115,10 @@ struct AgentPermissionPolicy { method == "assignTask" || method == "completeTask" || method == "rejectTask" || + method == "importTaskitemPool" || + method == "claimTaskitem" || + method == "releaseTaskitem" || + method == "completePooledTaskitem" || method == "saveWorkflow" || method == "routeTask" || method == "routeAllReady" || diff --git a/editor/src/ArchitectIntakeProcessor.h b/editor/src/ArchitectIntakeProcessor.h new file mode 100644 index 0000000..e9cdf62 --- /dev/null +++ b/editor/src/ArchitectIntakeProcessor.h @@ -0,0 +1,218 @@ +#pragma once + +#include "MarkdownSpecParser.h" +#include "RequirementNormalizationConflictDetector.h" +#include "RequirementsParser.h" + +#include + +#include +#include +#include + +struct IntakeResult { + std::vector normalizedRequirements; + std::vector conflicts; + nlohmann::json parsedSpec = nlohmann::json::object(); + int requirementCount = 0; + int fieldCount = 0; + int constraintCount = 0; + int conflictCount = 0; + bool hasConflicts = false; + bool success = false; + std::string error; +}; + +class ArchitectIntakeProcessor { +public: + static IntakeResult process(const std::string& markdown) { + IntakeResult out; + + ParsedMarkdownSpec parsed; + std::string error; + if (!MarkdownSpecParser::parse(markdown, &parsed, &error)) { + out.error = error; + return out; + } + + out.parsedSpec = parsedSpecToJson(parsed); + + RequirementNormalizationResult normalized; + bool normalizedOk = RequirementNormalizationConflictDetector::normalize(parsed, &normalized, &error); + if (!normalizedOk && error != "no_requirements_found") { + out.error = error; + return out; + } + + const std::vector parsedRequirements = RequirementsParser::parse(markdown); + if (!normalizedOk && parsedRequirements.empty()) { + out.error = error; + return out; + } + + int reqIndex = 1; + int goalIndex = 1; + int constraintIndex = 1; + int dependencyIndex = 1; + int acceptanceIndex = 1; + int featureIndex = 1; + int fieldIndex = 1; + std::set emittedKeys; + + for (const auto& req : normalized.requirements) { + out.normalizedRequirements.push_back(normalizedRequirementToJson(req)); + emittedKeys.insert(requirementDedupKey(req.anchor, req.sourceLine, req.normalizedText)); + if (req.kind == NormalizedRequirementKind::Constraint) ++out.constraintCount; + if (req.kind == NormalizedRequirementKind::Goal || req.kind == NormalizedRequirementKind::Requirement || + req.kind == NormalizedRequirementKind::Feature) { + ++out.requirementCount; + } + if (req.kind == NormalizedRequirementKind::Field) ++out.fieldCount; + } + + for (const auto& parsedReq : parsedRequirements) { + NormalizedRequirement req; + if (parsedReq.isField) { + req.requirementId = "field-" + std::to_string(fieldIndex++); + req.kind = NormalizedRequirementKind::Field; + } else { + req.kind = inferKindFromSectionAnchor(parsedReq.sectionAnchor); + if (req.kind == NormalizedRequirementKind::Goal) { + req.requirementId = "goal-" + std::to_string(goalIndex++); + } else if (req.kind == NormalizedRequirementKind::Constraint) { + req.requirementId = "constraint-" + std::to_string(constraintIndex++); + } else if (req.kind == NormalizedRequirementKind::Dependency) { + req.requirementId = "dependency-" + std::to_string(dependencyIndex++); + } else if (req.kind == NormalizedRequirementKind::Acceptance) { + req.requirementId = "acceptance-" + std::to_string(acceptanceIndex++); + } else if (req.kind == NormalizedRequirementKind::Feature) { + req.requirementId = "feature-" + std::to_string(featureIndex++); + } else { + req.requirementId = "requirement-" + std::to_string(reqIndex++); + } + } + req.normalizedText = parsedReq.text; + req.sourceText = parsedReq.sourceText.empty() ? parsedReq.text : parsedReq.sourceText; + req.anchor = parsedReq.sectionAnchor; + req.sourceLine = parsedReq.sourceLine; + req.ambiguous = isAmbiguous(parsedReq.text); + + const std::string dedupKey = requirementDedupKey(req.anchor, req.sourceLine, req.normalizedText); + if (emittedKeys.count(dedupKey) != 0) continue; + emittedKeys.insert(dedupKey); + + nlohmann::json j = normalizedRequirementToJson(req); + j["sourceText"] = parsedReq.sourceText; + j["sectionTitle"] = parsedReq.sectionTitle; + if (parsedReq.isField) { + j["fieldName"] = parsedReq.fieldName; + j["fieldType"] = parsedReq.fieldType; + ++out.fieldCount; + } else { + if (req.kind == NormalizedRequirementKind::Constraint) ++out.constraintCount; + ++out.requirementCount; + } + out.normalizedRequirements.push_back(j); + } + + for (const auto& conflict : normalized.conflicts) { + out.conflicts.push_back({ + {"leftRequirementId", conflict.leftRequirementId}, + {"rightRequirementId", conflict.rightRequirementId}, + {"conflictType", conflict.conflictType}, + {"detail", conflict.detail} + }); + } + + out.conflictCount = static_cast(out.conflicts.size()); + out.hasConflicts = !out.conflicts.empty(); + out.success = true; + return out; + } + +private: + static bool isAmbiguous(const std::string& text) { + const std::string lower = intakeToLower(text); + return lower.find("maybe") != std::string::npos || + lower.find("possibly") != std::string::npos || + lower.find("somehow") != std::string::npos || + lower.find("etc") != std::string::npos || + lower.find("nice") != std::string::npos; + } + + static std::string requirementDedupKey(const std::string& anchor, + int sourceLine, + const std::string& normalizedText) { + return anchor + "|" + std::to_string(sourceLine) + "|" + normalizedText; + } + + static NormalizedRequirementKind inferKindFromSectionAnchor(const std::string& anchor) { + if (anchor == "goals") return NormalizedRequirementKind::Goal; + if (anchor == "constraints" || + anchor == "observability" || + anchor == "security-safety" || + anchor == "performance-budgets" || + anchor == "rollout-migration-rollback") { + return NormalizedRequirementKind::Constraint; + } + if (anchor == "dependencies") return NormalizedRequirementKind::Dependency; + if (anchor == "acceptance-criteria") return NormalizedRequirementKind::Acceptance; + if (anchor == "features") return NormalizedRequirementKind::Feature; + return NormalizedRequirementKind::Requirement; + } + + static std::string requirementKindToString(NormalizedRequirementKind kind) { + if (kind == NormalizedRequirementKind::Goal) return "goal"; + if (kind == NormalizedRequirementKind::Constraint) return "constraint"; + if (kind == NormalizedRequirementKind::Dependency) return "dependency"; + if (kind == NormalizedRequirementKind::Acceptance) return "acceptance"; + if (kind == NormalizedRequirementKind::Requirement) return "requirement"; + if (kind == NormalizedRequirementKind::Feature) return "feature"; + if (kind == NormalizedRequirementKind::Field) return "field"; + return "goal"; + } + + static nlohmann::json normalizedRequirementToJson(const NormalizedRequirement& requirement) { + return { + {"requirementId", requirement.requirementId}, + {"kind", requirementKindToString(requirement.kind)}, + {"normalizedText", requirement.normalizedText}, + {"sourceText", requirement.sourceText.empty() ? requirement.normalizedText : requirement.sourceText}, + {"anchor", requirement.anchor}, + {"sourceLine", requirement.sourceLine}, + {"ambiguous", requirement.ambiguous} + }; + } + + static nlohmann::json parsedSpecToJson(const ParsedMarkdownSpec& parsed) { + nlohmann::json sections = nlohmann::json::array(); + for (const auto& section : parsed.sections) { + sections.push_back({ + {"title", section.title}, + {"anchor", section.anchor}, + {"headingLine", section.headingLine} + }); + } + + auto itemsToJson = [](const std::vector& items) { + nlohmann::json out = nlohmann::json::array(); + for (const auto& item : items) { + out.push_back({ + {"text", item.text}, + {"sectionTitle", item.sectionTitle}, + {"anchor", item.anchor}, + {"line", item.line} + }); + } + return out; + }; + + return { + {"sections", sections}, + {"goals", itemsToJson(parsed.goals)}, + {"constraints", itemsToJson(parsed.constraints)}, + {"dependencies", itemsToJson(parsed.dependencies)}, + {"acceptanceCriteria", itemsToJson(parsed.acceptanceCriteria)} + }; + } +}; diff --git a/editor/src/HeadlessAgentRPCHandler.h b/editor/src/HeadlessAgentRPCHandler.h index aa3db3e..43244d8 100644 --- a/editor/src/HeadlessAgentRPCHandler.h +++ b/editor/src/HeadlessAgentRPCHandler.h @@ -8,6 +8,7 @@ struct HeadlessEditorState; #include "HeadlessOrchestratorRPC.h" #include "GenerationQualityGates.h" +#include "TaskitemPoolPersistence.h" static inline json headlessRpcError(const json& id, int code, const std::string& msg) { diff --git a/editor/src/MCPServer.h b/editor/src/MCPServer.h index f564224..0ed7d2f 100644 --- a/editor/src/MCPServer.h +++ b/editor/src/MCPServer.h @@ -592,12 +592,26 @@ private: ms); sessionRecorder_.record(rec); } - std::string text = result.dump(2); - response["result"] = { - {"content", json::array({{{"type", "text"}, {"text", text}}})}, - {"isError", false}, - {"whetstoneVersionHeader", buildVersionHeader(toolName)} - }; + if (hasCallError(result)) { + const json& err = result["error"]; + std::string text; + if (err.is_object()) text = err.value("message", err.dump(2)); + else if (err.is_string()) text = err.get(); + else text = err.dump(2); + response["result"] = { + {"content", json::array({{{"type", "text"}, {"text", text}}})}, + {"isError", true}, + {"error", err}, + {"whetstoneVersionHeader", buildVersionHeader(toolName)} + }; + } else { + std::string text = result.dump(2); + response["result"] = { + {"content", json::array({{{"type", "text"}, {"text", text}}})}, + {"isError", false}, + {"whetstoneVersionHeader", buildVersionHeader(toolName)} + }; + } } catch (const std::exception& e) { response["result"] = { {"content", json::array({{{"type", "text"}, {"text", std::string("Error: ") + e.what()}}})}, @@ -708,7 +722,7 @@ private: }; json resp = rpcCallback_(request); if (resp.contains("result")) return resp["result"]; - if (resp.contains("error")) return {{"error", resp["error"]["message"]}}; + if (resp.contains("error")) return {{"error", resp["error"]}}; return resp; } @@ -1165,6 +1179,7 @@ private: #include "mcp/RegisterWorkflowTools.h" #include "mcp/RegisterWorkflowExecutionTools.h" #include "mcp/RegisterRoutingTools.h" +#include "mcp/RegisterTaskitemPoolTools.h" #include "mcp/RegisterOrchestratorTools.h" #include "mcp/RegisterReviewTools.h" #include "mcp/RegisterArchitectIntakeTools.h" diff --git a/editor/src/RequirementsParser.h b/editor/src/RequirementsParser.h index 2c3144b..bee9feb 100644 --- a/editor/src/RequirementsParser.h +++ b/editor/src/RequirementsParser.h @@ -29,22 +29,48 @@ public: std::string line; std::string sectionTitle; std::string sectionAnchor; + std::string proseBuffer; + int proseStartLine = 0; int lineNo = 0; + auto flushProse = [&]() { + if (proseBuffer.empty() || !isRequirementHeading(sectionAnchor)) return; + ParsedRequirement req; + req.text = normalizeText(proseBuffer); + req.sourceText = proseBuffer; + req.sourceLine = proseStartLine; + req.sectionAnchor = sectionAnchor; + req.sectionTitle = sectionTitle; + out.push_back(req); + proseBuffer.clear(); + proseStartLine = 0; + }; + while (std::getline(stream, line)) { ++lineNo; std::string trimmed = trim(line); if (isHeading(trimmed)) { + flushProse(); sectionTitle = trimHeading(trimmed); sectionAnchor = anchorize(sectionTitle); continue; } - if (!isRequirementHeading(sectionAnchor)) continue; + if (!isRequirementHeading(sectionAnchor)) { + flushProse(); + continue; + } + + if (trimmed.empty()) { + flushProse(); + continue; + } if (isBullet(trimmed)) { + flushProse(); std::string bulletText = trim(trimmed.substr(2)); if (bulletText.empty()) continue; + if (isQuotedExampleItem(bulletText)) continue; if (tryParseFieldList(bulletText, lineNo, sectionTitle, sectionAnchor, &out)) continue; ParsedRequirement req; req.text = normalizeText(bulletText); @@ -53,17 +79,38 @@ public: req.sectionAnchor = sectionAnchor; req.sectionTitle = sectionTitle; out.push_back(req); - } else if (isProseLine(trimmed)) { + } else if (isOrderedList(trimmed)) { + flushProse(); + std::size_t markerEnd = 0; + while (markerEnd < trimmed.size() && + std::isdigit(static_cast(trimmed[markerEnd]))) { + ++markerEnd; + } + std::string itemText = trim(trimmed.substr(markerEnd + 1)); + if (itemText.empty()) continue; + if (isQuotedExampleItem(itemText)) continue; ParsedRequirement req; - req.text = normalizeText(trimmed); - req.sourceText = trimmed; + req.text = normalizeText(itemText); + req.sourceText = itemText; req.sourceLine = lineNo; req.sectionAnchor = sectionAnchor; req.sectionTitle = sectionTitle; out.push_back(req); + } else if (isProseLine(trimmed)) { + if (proseBuffer.empty()) { + proseBuffer = trimmed; + proseStartLine = lineNo; + } else { + proseBuffer += " "; + proseBuffer += trimmed; + } + } else { + flushProse(); } } + flushProse(); + return out; } @@ -84,12 +131,32 @@ public: } static bool isRequirementHeading(const std::string& anchor) { - return anchor == "requirements" || + return anchor == "goals" || + anchor == "constraints" || + anchor == "acceptance-criteria" || + anchor == "required-artifacts" || + anchor == "observability" || + anchor == "security-safety" || + anchor == "performance-budgets" || + anchor == "rollout-migration-rollback" || + anchor == "environment-projection-targets" || + anchor == "requirements" || anchor == "features" || anchor == "behavior" || anchor == "behaviour" || anchor == "interface" || - anchor == "api"; + anchor == "api" || + containsToken(anchor, "purpose") || + containsToken(anchor, "objective") || + containsToken(anchor, "target") || + containsToken(anchor, "output") || + containsToken(anchor, "deliverable") || + containsToken(anchor, "scope") || + containsToken(anchor, "work") || + containsToken(anchor, "taskitem") || + containsToken(anchor, "execution") || + containsToken(anchor, "canonical") || + containsToken(anchor, "roadmap"); } private: @@ -125,13 +192,37 @@ private: return (line[0] == '-' || line[0] == '*') && line[1] == ' '; } + static bool isOrderedList(const std::string& line) { + std::size_t i = 0; + while (i < line.size() && std::isdigit(static_cast(line[i]))) ++i; + if (i == 0 || i + 1 >= line.size()) return false; + if (line[i] != '.' && line[i] != ')') return false; + return line[i + 1] == ' '; + } + static bool isProseLine(const std::string& trimmed) { if (trimmed.size() < 10) return false; if (isHeading(trimmed)) return false; + if (isOrderedList(trimmed)) return false; if (trimmed[0] == '`' || trimmed[0] == '|' || trimmed[0] == '>') return false; + if (!trimmed.empty() && trimmed.back() == ':') return false; return true; } + static bool containsToken(const std::string& text, const std::string& token) { + if (text == token) return true; + if (text.find(token + "-") != std::string::npos) return true; + if (text.find("-" + token) != std::string::npos) return true; + return false; + } + + static bool isQuotedExampleItem(const std::string& text) { + if (text.size() < 2) return false; + const char first = text.front(); + const char last = text.back(); + return ((first == '"' && last == '"') || (first == '\'' && last == '\'')); + } + static std::string maybeFieldType(const std::string& token) { std::size_t l = token.find('('); std::size_t r = token.rfind(')'); diff --git a/editor/src/ScopeMilestoneDecomposer.h b/editor/src/ScopeMilestoneDecomposer.h index 66063dd..bc0d010 100644 --- a/editor/src/ScopeMilestoneDecomposer.h +++ b/editor/src/ScopeMilestoneDecomposer.h @@ -4,12 +4,14 @@ #include "RequirementNormalizationConflictDetector.h" #include +#include #include #include struct DecomposedWorkstream { std::string workstreamId; std::string title; + std::string intent; std::vector requirementIds; int uncertaintyScore = 0; // 0..100 }; @@ -41,10 +43,12 @@ public: DecomposedScopePlan plan; plan.milestones.push_back(buildMilestone("milestone-1", "Intake Foundation", normalized, { NormalizedRequirementKind::Goal, - NormalizedRequirementKind::Constraint + NormalizedRequirementKind::Requirement, + NormalizedRequirementKind::Feature, + NormalizedRequirementKind::Field, + NormalizedRequirementKind::Dependency })); plan.milestones.push_back(buildMilestone("milestone-2", "Execution Readiness", normalized, { - NormalizedRequirementKind::Dependency, NormalizedRequirementKind::Acceptance })); @@ -60,6 +64,196 @@ public: } private: + static bool startsWith(const std::string& text, const std::string& prefix) { + return text.rfind(prefix, 0) == 0; + } + + static std::string lowerCopy(const std::string& text) { + std::string out = text; + for (char& c : out) { + if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); + } + return out; + } + + static bool isMetadataRequirement(const NormalizedRequirement& requirement) { + return startsWith(lowerCopy(requirement.normalizedText), "explicit target file:"); + } + + static bool containsAny(const std::string& text, const std::vector& needles) { + for (const auto& needle : needles) { + if (text.find(needle) != std::string::npos) return true; + } + return false; + } + + static bool isPlanningMetaRequirement(const NormalizedRequirement& requirement) { + const std::string lower = lowerCopy(requirement.normalizedText); + if (requirement.anchor == "environment-projection-targets" || + requirement.anchor == "observability" || + requirement.anchor == "performance-budgets" || + requirement.anchor == "rollout-migration-rollback") { + return true; + } + if (requirement.kind == NormalizedRequirementKind::Constraint && + containsAny(lower, { + "do not generate tasks", + "generated queue", + "task queue", + "planning only sprint", + "planner should", + "queue quality", + "planning run", + "small worker" + })) { + return true; + } + if (containsAny(lower, { + "active project is", + "target queue size is", + "p95 planning overhead", + "this sprint changes planning artifacts only" + })) { + return true; + } + return false; + } + + static std::string normalizeRequirementSummary(const std::string& text) { + std::string cleaned; + cleaned.reserve(text.size()); + bool lastWasSpace = false; + for (char c : text) { + const bool keep = (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_' || c == '/' || c == '.' || c == '-'; + if (keep) { + cleaned.push_back(c); + lastWasSpace = false; + } else if (!lastWasSpace) { + cleaned.push_back(' '); + lastWasSpace = true; + } + } + + std::vector words; + std::string current; + auto flush = [&]() { + if (current.empty()) return; + words.push_back(current); + current.clear(); + }; + for (char c : cleaned) { + if (c == ' ') flush(); + else current.push_back(c); + } + flush(); + + std::string summary; + int kept = 0; + for (const auto& word : words) { + const std::string lower = lowerCopy(word); + if (lower == "shall" || lower == "should" || lower == "must" || lower == "the") continue; + if (!summary.empty()) summary.push_back(' '); + summary += word; + if (++kept >= 8) break; + } + return summary.empty() ? text : summary; + } + + static std::string workstreamPrefix(NormalizedRequirementKind kind) { + if (kind == NormalizedRequirementKind::Goal) return "Plan"; + if (kind == NormalizedRequirementKind::Requirement) return "Implement"; + if (kind == NormalizedRequirementKind::Feature) return "Implement feature"; + if (kind == NormalizedRequirementKind::Field) return "Define field"; + if (kind == NormalizedRequirementKind::Constraint) return "Apply constraint"; + if (kind == NormalizedRequirementKind::Dependency) return "Integrate dependency"; + return "Verify"; + } + + static std::string workstreamGroupKey(const NormalizedRequirement& requirement) { + const std::string sourceText = requirement.sourceText.empty() + ? requirement.normalizedText + : requirement.sourceText; + const std::string lower = lowerCopy(sourceText); + + if (requirement.kind == NormalizedRequirementKind::Acceptance) return "verification"; + if (containsAny(lower, {"smithay", "dependency", "cargo", "substrate"})) return "smithay-bootstrap"; + if (containsAny(lower, {"focus", "stream", "binding", "coupled", "split", "routing", "state"})) { + return "focus-stream-state"; + } + if (containsAny(lower, {"compositor", "wayland", "bootstrap", "scaffold", "runtime"})) { + return "compositor-scaffold"; + } + if (containsAny(lower, {"doc", "readme", "roadmap", "vision"})) return "docs-alignment"; + if (containsAny(lower, {"build", "verify", "validation", "test"})) return "verification"; + if (requirement.kind == NormalizedRequirementKind::Field) return "state-definitions"; + return "implementation-foundation"; + } + + static std::string workstreamGroupTitle(const std::string& key) { + if (key == "smithay-bootstrap") return "Bootstrap Smithay workspace"; + if (key == "compositor-scaffold") return "Scaffold minimal compositor runtime"; + if (key == "focus-stream-state") return "Define focus-stream state model"; + if (key == "docs-alignment") return "Align docs with bootstrap architecture"; + if (key == "verification") return "Define verification and readiness surface"; + if (key == "state-definitions") return "Define foundational state types"; + return "Implement initial foundation slice"; + } + + static DecomposedWorkstream buildGroupedWorkstream(const std::string& workstreamId, + const std::string& key, + const std::vector& requirements) { + DecomposedWorkstream workstream; + workstream.workstreamId = workstreamId; + workstream.title = workstreamGroupTitle(key); + std::string intent; + for (const auto& requirement : requirements) { + const std::string sourceText = requirement.sourceText.empty() + ? requirement.normalizedText + : requirement.sourceText; + if (!intent.empty()) intent += " "; + intent += sourceText; + workstream.requirementIds.push_back(requirement.requirementId); + } + workstream.intent = intent; + workstream.uncertaintyScore = computeUncertainty(requirements); + return workstream; + } + + static DecomposedWorkstream buildRequirementWorkstream(const std::string& workstreamId, + const NormalizedRequirement& requirement) { + DecomposedWorkstream workstream; + workstream.workstreamId = workstreamId; + + // GR-029 fix (step 1989): use sourceText for intent + title so output + // preserves original capitalisation and class names. Fall back to + // normalizedText only when sourceText is absent (synthetic requirements). + const std::string& textForDisplay = requirement.sourceText.empty() + ? requirement.normalizedText + : requirement.sourceText; + workstream.intent = textForDisplay; + + // Build title: strip the leading verb from sourceText when it duplicates + // workstreamPrefix (e.g. "Implement ITargetResolver…" → prefix="Implement", + // strip leading "Implement " from textForDisplay before summarising). + const std::string prefix = workstreamPrefix(requirement.kind); + std::string titleSource = textForDisplay; + const std::string prefixLower = lowerCopy(prefix); + const std::string titleSourceLower = lowerCopy(titleSource); + if (titleSourceLower.rfind(prefixLower, 0) == 0 && + titleSource.size() > prefixLower.size() && + titleSource[prefixLower.size()] == ' ') { + titleSource = titleSource.substr(prefixLower.size() + 1); + } + workstream.title = prefix + " " + normalizeRequirementSummary(titleSource); + + workstream.requirementIds.push_back(requirement.requirementId); + workstream.uncertaintyScore = computeUncertainty({requirement}); + return workstream; + } + static DecomposedMilestone buildMilestone(const std::string& milestoneId, const std::string& title, const RequirementNormalizationResult& normalized, @@ -70,27 +264,38 @@ private: std::vector selected; for (const auto& requirement : normalized.requirements) { - if (containsKind(kinds, requirement.kind)) selected.push_back(requirement); + if (containsKind(kinds, requirement.kind) && + !isMetadataRequirement(requirement) && + !isPlanningMetaRequirement(requirement)) { + selected.push_back(requirement); + } } if (!selected.empty()) { - DecomposedWorkstream primary; - primary.workstreamId = milestoneId + "-ws-1"; - primary.title = title + " Primary"; - for (const auto& requirement : selected) primary.requirementIds.push_back(requirement.requirementId); - primary.uncertaintyScore = computeUncertainty(selected); - milestone.workstreams.push_back(primary); + std::map> grouped; + for (const auto& requirement : selected) { + grouped[workstreamGroupKey(requirement)].push_back(requirement); + } + + int extraIndex = 1; + for (const auto& kv : grouped) { + milestone.workstreams.push_back( + buildGroupedWorkstream(milestoneId + "-ws-" + std::to_string(extraIndex++), + kv.first, + kv.second)); + } DecomposedWorkstream review; - review.workstreamId = milestoneId + "-ws-2"; + review.workstreamId = milestoneId + "-ws-" + std::to_string(extraIndex++); review.title = "Architect Review"; + review.intent = "Resolve conflicting normalized requirements before execution."; for (const auto& conflict : normalized.conflicts) { review.requirementIds.push_back(conflict.leftRequirementId); review.requirementIds.push_back(conflict.rightRequirementId); } dedupeIds(&review.requirementIds); if (!review.requirementIds.empty()) { - review.uncertaintyScore = std::min(100, primary.uncertaintyScore + 20); + review.uncertaintyScore = std::min(100, computeUncertainty(selected) + 20); milestone.workstreams.push_back(review); } } @@ -113,6 +318,7 @@ private: if (requirement.ambiguous) score += 15; if (requirement.kind == NormalizedRequirementKind::Constraint) score += 5; if (requirement.kind == NormalizedRequirementKind::Acceptance) score -= 2; + if (requirement.kind == NormalizedRequirementKind::Field) score += 1; } if (requirements.size() > 6) score += 10; return std::max(0, std::min(100, score)); diff --git a/editor/src/SelfContainmentScorer.h b/editor/src/SelfContainmentScorer.h index 6f8a7b1..7eea4ae 100644 --- a/editor/src/SelfContainmentScorer.h +++ b/editor/src/SelfContainmentScorer.h @@ -24,6 +24,9 @@ struct TaskitemInput { std::vector dependencyTaskIds; int executionSpecificityScore = 0; bool strictExecutionContract = false; + bool hasTestFiles = false; + bool hasVerificationType = false; + std::string verificationType; bool capabilityCompileFailed = false; bool capabilityTestsFailed = false; bool capabilityToolchainMissing = false; @@ -60,12 +63,6 @@ public: if (item.reasons.empty()) { scoreValue -= 20; out.issues.push_back("reasons missing or empty"); - } else if (item.reasons.size() == 1) { - scoreValue -= 25; - out.issues.push_back("reasons has fewer than 2 entries"); - } else if (item.reasons.size() < 3) { - scoreValue -= 10; - out.issues.push_back("reasons has fewer than 3 entries"); } if (item.confidence == 0) { @@ -78,9 +75,9 @@ public: out.issues.push_back("title empty"); } - if (!item.dependencyTaskIds.empty()) { + if (item.dependencyTaskIds.size() > 1) { scoreValue -= 5; - out.issues.push_back("dependencyTaskIds not empty"); + out.issues.push_back("dependencyTaskIds include multiple upstream tasks"); } if (item.strictExecutionContract) { @@ -92,6 +89,22 @@ public: scoreValue -= 10; out.issues.push_back("execution contract metadata missing"); } + if (!item.hasVerificationType || item.verificationType.empty()) { + scoreValue -= 10; + out.issues.push_back("verificationType missing from execution contract"); + } else if (item.verificationType != "unit" && + item.verificationType != "integration" && + item.verificationType != "schema" && + item.verificationType != "smoke" && + item.verificationType != "docs") { + scoreValue -= 10; + out.issues.push_back("verificationType invalid for execution contract"); + } + if ((item.verificationType == "unit" || item.verificationType == "integration") && + !item.hasTestFiles) { + scoreValue -= 15; + out.issues.push_back("testFiles missing for code verification contract"); + } } if (item.capabilityCompileFailed || item.capabilityTestsFailed || item.capabilityToolchainMissing) { diff --git a/editor/src/TaskitemPoolPersistence.h b/editor/src/TaskitemPoolPersistence.h new file mode 100644 index 0000000..22c081a --- /dev/null +++ b/editor/src/TaskitemPoolPersistence.h @@ -0,0 +1,352 @@ +#pragma once +// Project-scoped taskitem pool persistence for external agent execution. + +#include "WorkflowPersistence.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using taskitem_pool_json = nlohmann::json; + +inline std::string taskitemPoolSanitizeProjectName(const std::string& value) { + std::string out; + out.reserve(value.size()); + for (char c : value) { + const bool keep = (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_'; + out.push_back(keep ? c : '-'); + } + while (!out.empty() && out.front() == '-') out.erase(out.begin()); + while (!out.empty() && out.back() == '-') out.pop_back(); + return out.empty() ? "project" : out; +} + +inline std::string taskitemPoolInferProjectName(const std::string& workspaceRoot, + const std::string& requested) { + if (!requested.empty()) return taskitemPoolSanitizeProjectName(requested); + namespace fs = std::filesystem; + if (!workspaceRoot.empty()) { + std::error_code ec; + fs::path root = fs::weakly_canonical(fs::path(workspaceRoot), ec); + if (!ec && !root.empty()) { + const std::string leaf = root.filename().string(); + if (!leaf.empty()) return taskitemPoolSanitizeProjectName(leaf); + } + } + return "project"; +} + +inline std::string taskitemPoolSidecarPath(const std::string& workspaceRoot, + const std::string& projectName) { + return workspaceRoot + "/.whetstone/" + taskitemPoolSanitizeProjectName(projectName) + + ".taskitem_pool.json"; +} + +inline std::string taskitemPoolLockPath(const std::string& workspaceRoot, + const std::string& projectName) { + return workspaceRoot + "/.whetstone/" + taskitemPoolSanitizeProjectName(projectName) + + ".taskitem_pool.lock"; +} + +struct TaskitemPoolLockGuard { + int fd = -1; + std::string path; + + TaskitemPoolLockGuard() = default; + TaskitemPoolLockGuard(const TaskitemPoolLockGuard&) = delete; + TaskitemPoolLockGuard& operator=(const TaskitemPoolLockGuard&) = delete; + + TaskitemPoolLockGuard(TaskitemPoolLockGuard&& other) noexcept + : fd(other.fd), path(std::move(other.path)) { + other.fd = -1; + } + + TaskitemPoolLockGuard& operator=(TaskitemPoolLockGuard&& other) noexcept { + if (this == &other) return *this; + release(); + fd = other.fd; + path = std::move(other.path); + other.fd = -1; + return *this; + } + + ~TaskitemPoolLockGuard() { release(); } + + bool valid() const { return fd >= 0; } + + void release() { + if (fd < 0) return; + flock(fd, LOCK_UN); + close(fd); + fd = -1; + } +}; + +inline std::optional acquireTaskitemPoolLock( + const std::string& workspaceRoot, + const std::string& projectName, + bool exclusive, + std::string* error = nullptr) { + TaskitemPoolLockGuard guard; + guard.path = taskitemPoolLockPath(workspaceRoot, projectName); + if (!ensureDirectoryExists(guard.path)) { + if (error) *error = "taskitem_pool_lock_dir_failed"; + return std::nullopt; + } + + guard.fd = open(guard.path.c_str(), O_RDWR | O_CREAT, 0666); + if (guard.fd < 0) { + if (error) *error = "taskitem_pool_lock_open_failed"; + return std::nullopt; + } + + const int lockMode = exclusive ? LOCK_EX : LOCK_SH; + if (flock(guard.fd, lockMode) != 0) { + if (error) *error = "taskitem_pool_lock_failed"; + close(guard.fd); + guard.fd = -1; + return std::nullopt; + } + + return guard; +} + +struct TaskitemPoolEntry { + std::string itemId; + std::string taskId; + std::string title; + std::string intent; + std::string milestoneId; + std::vector requirementIds; + std::vector dependencyTaskIds; + std::vector resourceLocks; + std::string status = "ready"; + std::string assignee; + std::string workerType; + std::string importedAt; + std::string claimedAt; + std::string completedAt; + std::string sourceLabel; + taskitem_pool_json executionContract = taskitem_pool_json::object(); + taskitem_pool_json task = taskitem_pool_json::object(); + taskitem_pool_json result = taskitem_pool_json::object(); + + taskitem_pool_json toJson() const { + return { + {"itemId", itemId}, + {"taskId", taskId}, + {"title", title}, + {"intent", intent}, + {"milestoneId", milestoneId}, + {"requirementIds", requirementIds}, + {"dependencyTaskIds", dependencyTaskIds}, + {"resourceLocks", resourceLocks}, + {"status", status}, + {"assignee", assignee}, + {"workerType", workerType}, + {"importedAt", importedAt}, + {"claimedAt", claimedAt}, + {"completedAt", completedAt}, + {"sourceLabel", sourceLabel}, + {"executionContract", executionContract}, + {"task", task}, + {"result", result} + }; + } + + static TaskitemPoolEntry fromJson(const taskitem_pool_json& j) { + TaskitemPoolEntry out; + out.itemId = j.value("itemId", ""); + out.taskId = j.value("taskId", out.itemId); + out.title = j.value("title", ""); + out.intent = j.value("intent", ""); + out.milestoneId = j.value("milestoneId", ""); + out.requirementIds = j.value("requirementIds", std::vector{}); + out.dependencyTaskIds = j.value("dependencyTaskIds", std::vector{}); + out.resourceLocks = j.value("resourceLocks", std::vector{}); + out.status = j.value("status", "ready"); + out.assignee = j.value("assignee", ""); + out.workerType = j.value("workerType", ""); + out.importedAt = j.value("importedAt", ""); + out.claimedAt = j.value("claimedAt", ""); + out.completedAt = j.value("completedAt", ""); + out.sourceLabel = j.value("sourceLabel", ""); + out.executionContract = j.value("executionContract", taskitem_pool_json::object()); + out.task = j.value("task", taskitem_pool_json::object()); + out.result = j.value("result", taskitem_pool_json::object()); + return out; + } +}; + +struct TaskitemPoolState { + std::string projectName; + std::string createdAt; + std::string updatedAt; + std::vector items; + + taskitem_pool_json toJson() const { + taskitem_pool_json arr = taskitem_pool_json::array(); + for (const auto& item : items) arr.push_back(item.toJson()); + return { + {"projectName", projectName}, + {"createdAt", createdAt}, + {"updatedAt", updatedAt}, + {"items", arr} + }; + } + + static TaskitemPoolState fromJson(const taskitem_pool_json& j) { + TaskitemPoolState state; + state.projectName = j.value("projectName", ""); + state.createdAt = j.value("createdAt", ""); + state.updatedAt = j.value("updatedAt", ""); + for (const auto& item : j.value("items", taskitem_pool_json::array())) { + if (!item.is_object()) continue; + state.items.push_back(TaskitemPoolEntry::fromJson(item)); + } + return state; + } +}; + +struct TaskitemPoolSaveResult { + bool success = false; + std::string path; + int bytesWritten = 0; + int itemCount = 0; +}; + +inline std::optional loadTaskitemPool(const std::string& workspaceRoot, + const std::string& projectName) { + const std::string path = taskitemPoolSidecarPath(workspaceRoot, projectName); + std::ifstream ifs(path); + if (!ifs.is_open()) return std::nullopt; + std::ostringstream oss; + oss << ifs.rdbuf(); + const std::string content = oss.str(); + if (content.empty()) return std::nullopt; + try { + return TaskitemPoolState::fromJson(taskitem_pool_json::parse(content)); + } catch (...) { + return std::nullopt; + } +} + +inline TaskitemPoolSaveResult saveTaskitemPool(const std::string& workspaceRoot, + const TaskitemPoolState& state) { + namespace fs = std::filesystem; + TaskitemPoolSaveResult out; + out.path = taskitemPoolSidecarPath(workspaceRoot, state.projectName); + if (!ensureDirectoryExists(out.path)) return out; + const std::string content = state.toJson().dump(2); + const std::string tempPath = out.path + ".tmp"; + std::ofstream ofs(tempPath, std::ios::trunc); + if (!ofs.is_open()) return out; + ofs << content; + ofs.close(); + if (ofs.fail()) return out; + std::error_code ec; + fs::rename(fs::path(tempPath), fs::path(out.path), ec); + if (ec) { + fs::remove(fs::path(out.path), ec); + ec.clear(); + fs::rename(fs::path(tempPath), fs::path(out.path), ec); + } + if (ec) return out; + out.success = true; + out.bytesWritten = static_cast(content.size()); + out.itemCount = static_cast(state.items.size()); + return out; +} + +inline taskitem_pool_json taskitemPoolCounts(const TaskitemPoolState& state) { + std::map counts; + for (const auto& item : state.items) counts[item.status] += 1; + taskitem_pool_json out = taskitem_pool_json::object(); + out["total"] = static_cast(state.items.size()); + for (const auto& kv : counts) out[kv.first] = kv.second; + return out; +} + +inline std::optional loadTaskitemImportPayload(const std::string& workspaceRoot, + const std::string& path, + std::string* error) { + namespace fs = std::filesystem; + fs::path input(path); + if (input.is_relative()) input = fs::path(workspaceRoot) / input; + std::ifstream ifs(input); + if (!ifs.is_open()) { + if (error) *error = "taskitems_file_not_found"; + return std::nullopt; + } + std::ostringstream oss; + oss << ifs.rdbuf(); + try { + return taskitem_pool_json::parse(oss.str()); + } catch (...) { + if (error) *error = "taskitems_file_parse_failed"; + return std::nullopt; + } +} + +inline taskitem_pool_json extractTaskitemArray(const taskitem_pool_json& payload) { + if (payload.is_array()) return payload; + if (payload.is_object() && payload.contains("tasks") && payload["tasks"].is_array()) { + return payload["tasks"]; + } + return taskitem_pool_json::array(); +} + +inline TaskitemPoolEntry taskitemPoolEntryFromTask(const taskitem_pool_json& task, + const std::string& sourceLabel) { + TaskitemPoolEntry out; + out.itemId = task.value("taskId", ""); + out.taskId = out.itemId; + out.title = task.value("title", ""); + out.intent = task.value("intent", ""); + out.milestoneId = task.value("milestoneId", ""); + out.requirementIds = task.value("requirementIds", std::vector{}); + out.dependencyTaskIds = task.value("dependencyTaskIds", std::vector{}); + out.resourceLocks = task.value("resourceLocks", std::vector{}); + out.status = task.value("queueReady", true) ? "ready" : "blocked"; + out.importedAt = workItemTimestamp(); + out.sourceLabel = sourceLabel; + out.executionContract = task.value("executionContract", taskitem_pool_json::object()); + out.task = task; + return out; +} + +inline bool taskitemDependenciesSatisfied(const TaskitemPoolState& state, + const TaskitemPoolEntry& item) { + if (item.dependencyTaskIds.empty()) return true; + std::map statusByTaskId; + for (const auto& candidate : state.items) statusByTaskId[candidate.taskId] = candidate.status; + for (const auto& dep : item.dependencyTaskIds) { + auto it = statusByTaskId.find(dep); + if (it == statusByTaskId.end()) continue; + if (it->second != "complete") return false; + } + return true; +} + +inline void recomputeTaskitemPoolStatuses(TaskitemPoolState* state) { + if (!state) return; + for (auto& item : state->items) { + if (item.status == "complete" || item.status == "claimed") continue; + item.status = taskitemDependenciesSatisfied(*state, item) ? "ready" : "blocked"; + } + state->updatedAt = workItemTimestamp(); +} diff --git a/editor/src/headless_rpc/DispatchPart5.h b/editor/src/headless_rpc/DispatchPart5.h index b2fe1d8..0159c7f 100644 --- a/editor/src/headless_rpc/DispatchPart5.h +++ b/editor/src/headless_rpc/DispatchPart5.h @@ -265,6 +265,306 @@ }); } + auto resolveTaskitemPoolProject = [&](const json& params) { + const std::string requested = params.value("projectName", ""); + return taskitemPoolInferProjectName(state.workspaceRoot, requested); + }; + + // --- importTaskitemPool --- + if (method == "importTaskitemPool") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (state.workspaceRoot.empty()) + return headlessRpcError(id, -32000, "No workspace root"); + + auto params = request.contains("params") ? request["params"] : json::object(); + const std::string projectName = resolveTaskitemPoolProject(params); + const bool replace = params.value("replace", false); + const std::string sourceLabel = params.value("sourceLabel", params.value("taskitemsPath", "")); + std::string lockError; + auto poolLock = acquireTaskitemPoolLock(state.workspaceRoot, projectName, true, &lockError); + if (!poolLock.has_value()) + return headlessRpcError(id, -32000, lockError); + + taskitem_pool_json payload = params.value("taskitems", taskitem_pool_json::array()); + if ((!payload.is_array() || payload.empty()) && params.value("taskitemsPath", std::string()).size() > 0) { + std::string importError; + auto loaded = loadTaskitemImportPayload(state.workspaceRoot, + params.value("taskitemsPath", ""), + &importError); + if (!loaded.has_value()) return headlessRpcError(id, -32000, importError); + payload = loaded.value(); + } + + taskitem_pool_json tasks = extractTaskitemArray(payload); + if (!tasks.is_array() || tasks.empty()) + return headlessRpcError(id, -32602, "No taskitems to import"); + + TaskitemPoolState pool; + if (auto existing = loadTaskitemPool(state.workspaceRoot, projectName); existing.has_value()) { + pool = existing.value(); + } else { + pool.projectName = projectName; + pool.createdAt = workItemTimestamp(); + } + if (replace) pool.items.clear(); + + int importedCount = 0; + int updatedCount = 0; + for (const auto& task : tasks) { + if (!task.is_object()) continue; + TaskitemPoolEntry entry = taskitemPoolEntryFromTask(task, sourceLabel); + if (entry.itemId.empty()) continue; + + bool replaced = false; + for (auto& existing : pool.items) { + if (existing.itemId != entry.itemId) continue; + const std::string priorStatus = existing.status; + const std::string priorAssignee = existing.assignee; + const std::string priorClaimedAt = existing.claimedAt; + const std::string priorCompletedAt = existing.completedAt; + const taskitem_pool_json priorResult = existing.result; + existing = entry; + if (!replace && (priorStatus == "claimed" || priorStatus == "complete")) { + existing.status = priorStatus; + existing.assignee = priorAssignee; + existing.claimedAt = priorClaimedAt; + existing.completedAt = priorCompletedAt; + existing.result = priorResult; + } + replaced = true; + ++updatedCount; + break; + } + if (!replaced) { + pool.items.push_back(entry); + ++importedCount; + } + } + + recomputeTaskitemPoolStatuses(&pool); + auto saveResult = saveTaskitemPool(state.workspaceRoot, pool); + if (!saveResult.success) { + return headlessRpcError(id, -32000, "Unable to save taskitem pool"); + } + state.eventStream.emit({ + "taskitem_pool.imported", + "", + { + {"projectName", projectName}, + {"importedCount", importedCount}, + {"updatedCount", updatedCount}, + {"counts", taskitemPoolCounts(pool)} + }, + workItemTimestamp() + }); + return headlessRpcResult(id, { + {"success", true}, + {"projectName", projectName}, + {"path", saveResult.path}, + {"importedCount", importedCount}, + {"updatedCount", updatedCount}, + {"itemCount", saveResult.itemCount}, + {"counts", taskitemPoolCounts(pool)} + }); + } + + // --- listTaskitemPool --- + if (method == "listTaskitemPool") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (state.workspaceRoot.empty()) + return headlessRpcError(id, -32000, "No workspace root"); + + auto params = request.contains("params") ? request["params"] : json::object(); + const std::string projectName = resolveTaskitemPoolProject(params); + std::string lockError; + auto poolLock = acquireTaskitemPoolLock(state.workspaceRoot, projectName, false, &lockError); + if (!poolLock.has_value()) + return headlessRpcError(id, -32000, lockError); + auto pool = loadTaskitemPool(state.workspaceRoot, projectName); + if (!pool.has_value()) + return headlessRpcError(id, -32000, "Taskitem pool not found"); + + recomputeTaskitemPoolStatuses(&pool.value()); + const std::string statusFilter = params.value("status", ""); + json items = json::array(); + for (const auto& item : pool->items) { + if (!statusFilter.empty() && item.status != statusFilter) continue; + items.push_back(item.toJson()); + } + return headlessRpcResult(id, { + {"projectName", projectName}, + {"items", items}, + {"count", (int)items.size()}, + {"counts", taskitemPoolCounts(*pool)} + }); + } + + // --- claimTaskitem --- + if (method == "claimTaskitem") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (state.workspaceRoot.empty()) + return headlessRpcError(id, -32000, "No workspace root"); + + auto params = request.contains("params") ? request["params"] : json::object(); + const std::string projectName = resolveTaskitemPoolProject(params); + const std::string itemId = params.value("itemId", ""); + const std::string assignee = params.value("assignee", ""); + if (itemId.empty() || assignee.empty()) + return headlessRpcError(id, -32602, "Missing itemId or assignee"); + std::string lockError; + auto poolLock = acquireTaskitemPoolLock(state.workspaceRoot, projectName, true, &lockError); + if (!poolLock.has_value()) + return headlessRpcError(id, -32000, lockError); + + auto pool = loadTaskitemPool(state.workspaceRoot, projectName); + if (!pool.has_value()) + return headlessRpcError(id, -32000, "Taskitem pool not found"); + + recomputeTaskitemPoolStatuses(&pool.value()); + TaskitemPoolEntry* claimed = nullptr; + for (auto& item : pool->items) { + if (item.itemId != itemId) continue; + if (item.status != "ready") + return headlessRpcError(id, -32000, "Taskitem not ready"); + item.status = "claimed"; + item.assignee = assignee; + item.workerType = params.value("workerType", item.workerType.empty() ? "llm" : item.workerType); + item.claimedAt = workItemTimestamp(); + claimed = &item; + break; + } + if (!claimed) + return headlessRpcError(id, -32602, "Taskitem not found"); + + auto saveResult = saveTaskitemPool(state.workspaceRoot, *pool); + if (!saveResult.success) + return headlessRpcError(id, -32000, "Unable to save taskitem pool"); + state.eventStream.emit({ + "taskitem_pool.claimed", + itemId, + {{"projectName", projectName}, {"assignee", assignee}, {"workerType", claimed->workerType}}, + workItemTimestamp() + }); + return headlessRpcResult(id, { + {"success", true}, + {"projectName", projectName}, + {"item", claimed->toJson()}, + {"counts", taskitemPoolCounts(*pool)} + }); + } + + // --- releaseTaskitem --- + if (method == "releaseTaskitem") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (state.workspaceRoot.empty()) + return headlessRpcError(id, -32000, "No workspace root"); + + auto params = request.contains("params") ? request["params"] : json::object(); + const std::string projectName = resolveTaskitemPoolProject(params); + const std::string itemId = params.value("itemId", ""); + if (itemId.empty()) + return headlessRpcError(id, -32602, "Missing itemId"); + std::string lockError; + auto poolLock = acquireTaskitemPoolLock(state.workspaceRoot, projectName, true, &lockError); + if (!poolLock.has_value()) + return headlessRpcError(id, -32000, lockError); + + auto pool = loadTaskitemPool(state.workspaceRoot, projectName); + if (!pool.has_value()) + return headlessRpcError(id, -32000, "Taskitem pool not found"); + + TaskitemPoolEntry* released = nullptr; + for (auto& item : pool->items) { + if (item.itemId != itemId) continue; + if (item.status != "claimed") + return headlessRpcError(id, -32000, "Taskitem not claimed"); + const std::string expectedAssignee = params.value("assignee", ""); + if (!expectedAssignee.empty() && item.assignee != expectedAssignee) + return headlessRpcError(id, -32000, "Taskitem claimed by different assignee"); + item.status = "ready"; + item.assignee.clear(); + item.claimedAt.clear(); + released = &item; + break; + } + if (!released) + return headlessRpcError(id, -32602, "Taskitem not found"); + + recomputeTaskitemPoolStatuses(&pool.value()); + auto saveResult = saveTaskitemPool(state.workspaceRoot, *pool); + if (!saveResult.success) + return headlessRpcError(id, -32000, "Unable to save taskitem pool"); + state.eventStream.emit({ + "taskitem_pool.released", + itemId, + {{"projectName", projectName}, {"reason", params.value("reason", "")}}, + workItemTimestamp() + }); + return headlessRpcResult(id, { + {"success", true}, + {"projectName", projectName}, + {"item", released->toJson()}, + {"counts", taskitemPoolCounts(*pool)} + }); + } + + // --- completePooledTaskitem --- + if (method == "completePooledTaskitem") { + if (!AgentPermissionPolicy::canInvoke(role, method)) + return headlessRpcError(id, -32031, "Role not permitted"); + if (state.workspaceRoot.empty()) + return headlessRpcError(id, -32000, "No workspace root"); + + auto params = request.contains("params") ? request["params"] : json::object(); + const std::string projectName = resolveTaskitemPoolProject(params); + const std::string itemId = params.value("itemId", ""); + if (itemId.empty()) + return headlessRpcError(id, -32602, "Missing itemId"); + std::string lockError; + auto poolLock = acquireTaskitemPoolLock(state.workspaceRoot, projectName, true, &lockError); + if (!poolLock.has_value()) + return headlessRpcError(id, -32000, lockError); + + auto pool = loadTaskitemPool(state.workspaceRoot, projectName); + if (!pool.has_value()) + return headlessRpcError(id, -32000, "Taskitem pool not found"); + + TaskitemPoolEntry* completed = nullptr; + for (auto& item : pool->items) { + if (item.itemId != itemId) continue; + if (item.status != "claimed" && item.status != "ready") + return headlessRpcError(id, -32000, "Taskitem not claimable for completion"); + item.status = "complete"; + item.completedAt = workItemTimestamp(); + item.result = params.value("result", json::object()); + completed = &item; + break; + } + if (!completed) + return headlessRpcError(id, -32602, "Taskitem not found"); + + recomputeTaskitemPoolStatuses(&pool.value()); + auto saveResult = saveTaskitemPool(state.workspaceRoot, *pool); + if (!saveResult.success) + return headlessRpcError(id, -32000, "Unable to save taskitem pool"); + state.eventStream.emit({ + "taskitem_pool.completed", + itemId, + {{"projectName", projectName}}, + workItemTimestamp() + }); + return headlessRpcResult(id, { + {"success", true}, + {"projectName", projectName}, + {"item", completed->toJson()}, + {"counts", taskitemPoolCounts(*pool)} + }); + } + // --- createWorkflow --- if (method == "createWorkflow") { if (!AgentPermissionPolicy::canInvoke(role, method)) diff --git a/editor/src/mcp/RegisterArchitectIntakeTools.h b/editor/src/mcp/RegisterArchitectIntakeTools.h index 2c37213..6511fe8 100644 --- a/editor/src/mcp/RegisterArchitectIntakeTools.h +++ b/editor/src/mcp/RegisterArchitectIntakeTools.h @@ -23,7 +23,9 @@ {"libraryPolicy", {{"type", "object"}, {"description", "Library selection policy: mode=strict|hybrid, expansionMode=auto_include|ask_user|suggest_options."}}}, {"strictExecutionContract", {{"type", "boolean"}, - {"description", "Require concrete execution contract metadata (stepIds/files/tools/tests)."}}} + {"description", "Require concrete execution contract metadata (stepIds/files/tools/tests)."}}}, + {"workspace", {{"type", "string"}, + {"description", "Optional active workspace root for workspace-aware target inference."}}} }}, {"required", json::array({"normalizedRequirements"})}} }); toolHandlers_["whetstone_generate_taskitems"] = @@ -199,6 +201,19 @@ c == '_' || c == '/' || c == '.' || c == '-'; } + static bool containsStandaloneToken(const std::string& text, const std::string& token) { + if (text.empty() || token.empty()) return false; + std::size_t pos = 0; + while ((pos = text.find(token, pos)) != std::string::npos) { + const bool leftOk = pos == 0 || !isTokenChar(text[pos - 1]); + const std::size_t end = pos + token.size(); + const bool rightOk = end >= text.size() || !isTokenChar(text[end]); + if (leftOk && rightOk) return true; + pos = end; + } + return false; + } + static void appendUnique(std::vector* out, const std::string& value) { if (!out || value.empty()) return; for (const auto& existing : *out) { @@ -347,7 +362,8 @@ std::vector files; std::vector tools; std::vector commands; - std::vector repoSignals; + std::vector repoSignalCandidates; + bool explicitCrossProjectIntent = false; const std::vector knownCrossProjectRepos = { "cad_orchestrator", @@ -359,8 +375,18 @@ }; for (const auto& requirement : requirements) { + const std::string sourceText = requirement.sourceText.empty() + ? requirement.normalizedText + : requirement.sourceText; const std::string text = requirement.normalizedText; - const std::string lower = intakeToLowerCopy(text); + const std::string lower = intakeToLowerCopy(sourceText); + + if (lower.find("cross-repo") != std::string::npos || + lower.find("multi-project") != std::string::npos || + lower.find("multi-repo") != std::string::npos || + lower.find("ecosystem") != std::string::npos) { + explicitCrossProjectIntent = true; + } std::size_t pos = 0; while ((pos = lower.find("step ", pos)) != std::string::npos) { @@ -383,16 +409,16 @@ } pos = 0; - while ((pos = text.find('`', pos)) != std::string::npos) { - std::size_t end = text.find('`', pos + 1); + while ((pos = sourceText.find('`', pos)) != std::string::npos) { + std::size_t end = sourceText.find('`', pos + 1); if (end == std::string::npos) break; - std::string candidate = text.substr(pos + 1, end - pos - 1); + std::string candidate = sourceText.substr(pos + 1, end - pos - 1); std::string normalizedPath = normalizeTargetFileCandidate(candidate); if (!normalizedPath.empty()) appendUnique(&files, normalizedPath); pos = end + 1; } - const auto inlinePathTokens = extractPathTokensFromText(text); + const auto inlinePathTokens = extractPathTokensFromText(sourceText); for (const auto& p : inlinePathTokens) appendUnique(&files, p); pos = 0; @@ -413,10 +439,15 @@ if (lower.find("cmake --build") != std::string::npos) appendUnique(&commands, "cmake --build ."); for (const auto& repo : knownCrossProjectRepos) { - if (lower.find(repo) != std::string::npos) appendUnique(&repoSignals, repo); + if (containsStandaloneToken(lower, repo)) appendUnique(&repoSignalCandidates, repo); } } + std::vector repoSignals; + if (explicitCrossProjectIntent || sortedUnique(repoSignalCandidates).size() >= 2) { + repoSignals = sortedUnique(repoSignalCandidates); + } + return { {"stepIds", sortedUnique(stepIds)}, {"targetFiles", sortedUnique(files)}, @@ -456,6 +487,73 @@ return sortedUnique(targets); } + static json inferWorkspaceFallbackTargets(const std::string& workspaceRoot) { + namespace fs = std::filesystem; + if (workspaceRoot.empty()) return json::array(); + std::error_code ec; + fs::path root = fs::weakly_canonical(fs::path(workspaceRoot), ec); + if (ec || root.empty() || !fs::exists(root, ec) || !fs::is_directory(root, ec)) { + return json::array(); + } + + std::vector targets; + auto appendIfExists = [&](const fs::path& rel) { + if (targets.size() >= 4) return; + std::error_code localEc; + const fs::path abs = root / rel; + if (fs::exists(abs, localEc) && fs::is_regular_file(abs, localEc)) { + appendUnique(&targets, rel.generic_string()); + } + }; + + appendIfExists("Cargo.toml"); + appendIfExists("pyproject.toml"); + appendIfExists("package.json"); + appendIfExists("go.mod"); + appendIfExists("CMakeLists.txt"); + appendIfExists("README.md"); + appendIfExists("src/main.rs"); + appendIfExists("src/lib.rs"); + + if (targets.size() < 4) { + const std::vector preferredNested = { + fs::path("crates") / "whimpwm" / "Cargo.toml", + fs::path("crates") / "whimpwm" / "src" / "main.rs", + fs::path("crates") / "whimpwm" / "src" / "lib.rs" + }; + for (const auto& rel : preferredNested) appendIfExists(rel); + } + + if (targets.size() < 4) { + auto endsWithLocal = [](const std::string& value, const std::string& suffix) { + return value.size() >= suffix.size() && + value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; + }; + for (fs::recursive_directory_iterator it(root, ec), end; !ec && it != end && targets.size() < 4; it.increment(ec)) { + if (ec) break; + if (!it->is_regular_file(ec)) continue; + const fs::path rel = it->path().lexically_relative(root); + const std::string relStr = rel.generic_string(); + if (relStr.rfind(".git/", 0) == 0 || relStr.rfind("build/", 0) == 0 || relStr.rfind("target/", 0) == 0) { + continue; + } + const std::string lower = intakeToLowerCopy(relStr); + const bool interesting = endsWithLocal(lower, ".rs") || + lower == "readme.md" || + lower == "cargo.toml" || + lower == "package.json" || + lower == "pyproject.toml" || + lower == "go.mod" || + lower == "cmakelists.txt"; + if (interesting) appendUnique(&targets, relStr); + } + } + + json out = json::array(); + for (const auto& file : sortedUnique(targets)) out.push_back(file); + return out; + } + static bool hasCrossProjectIntent(const std::vector& requirements) { int hitCount = 0; for (const auto& requirement : requirements) { @@ -466,10 +564,10 @@ lower.find("ecosystem") != std::string::npos) { return true; } - if (lower.find("cad_orchestrator") != std::string::npos) ++hitCount; - if (lower.find("hivemind") != std::string::npos) ++hitCount; - if (lower.find("gleaner") != std::string::npos) ++hitCount; - if (lower.find("constcad") != std::string::npos) ++hitCount; + if (containsStandaloneToken(lower, "cad_orchestrator")) ++hitCount; + if (containsStandaloneToken(lower, "hivemind")) ++hitCount; + if (containsStandaloneToken(lower, "gleaner")) ++hitCount; + if (containsStandaloneToken(lower, "constcad")) ++hitCount; if (hitCount >= 2) return true; } return false; @@ -500,26 +598,29 @@ return normalizeTargetFileCandidate(raw); } - static std::string requirementIdFromSliceTitle(const std::string& title) { - const std::string prefix = "Requirement Slice "; - if (title.rfind(prefix, 0) != 0) return ""; - std::string id = title.substr(prefix.size()); - return trimPathToken(id); - } - static json selectTaskScopedTargetFiles(const AnnotatedTaskitem& task, const std::vector& requirements, const json& defaultTargetFiles) { std::vector scoped; - const std::string sliceRequirementId = requirementIdFromSliceTitle(task.base.title); for (const auto& requirement : requirements) { - const std::string file = parseExplicitTargetFileFromRequirementText(requirement.normalizedText); - if (file.empty()) continue; - const bool exactMatch = !sliceRequirementId.empty() && requirement.requirementId == sliceRequirementId; - const bool containsMatch = !sliceRequirementId.empty() && - task.base.title.find(requirement.requirementId) != std::string::npos; - if (exactMatch || containsMatch) { - appendUnique(&scoped, file); + bool matchesTask = false; + for (const auto& requirementId : task.base.requirementIds) { + if (requirementId == requirement.requirementId) { + matchesTask = true; + break; + } + } + if (!matchesTask) continue; + + const std::string sourceText = requirement.sourceText.empty() + ? requirement.normalizedText + : requirement.sourceText; + const std::string file = parseExplicitTargetFileFromRequirementText(sourceText); + if (!file.empty()) appendUnique(&scoped, file); + + const auto inlinePathTokens = extractPathTokensFromText(sourceText); + for (const auto& path : inlinePathTokens) { + appendUnique(&scoped, path); } } if (scoped.empty()) return defaultTargetFiles; @@ -528,6 +629,68 @@ return out; } + static json inferWorkspaceTaskTargets(const AnnotatedTaskitem& task, + const std::string& workspaceRoot) { + namespace fs = std::filesystem; + std::vector targets; + const std::string lowerTitle = intakeToLowerCopy(task.base.title + " " + task.base.intent); + const fs::path root(workspaceRoot); + const bool hasCargoWorkspace = !workspaceRoot.empty() && fs::exists(root / "Cargo.toml"); + const fs::path crateCargo = root / "crates" / "whimpwm" / "Cargo.toml"; + const fs::path crateMain = root / "crates" / "whimpwm" / "src" / "main.rs"; + const fs::path crateFocus = root / "crates" / "whimpwm" / "src" / "focus_streams.rs"; + const fs::path crateCompositor = root / "crates" / "whimpwm" / "src" / "compositor.rs"; + const fs::path readme = root / "README.md"; + const fs::path vision = root / "docs" / "vision.md"; + + auto pushRelative = [&](const fs::path& path) { + if (workspaceRoot.empty()) return; + std::error_code ec; + const fs::path rel = fs::relative(path, root, ec); + if (ec) return; + appendUnique(&targets, rel.generic_string()); + }; + + if (containsStandaloneToken(lowerTitle, "smithay") || + lowerTitle.find("dependency") != std::string::npos || + lowerTitle.find("cargo") != std::string::npos) { + if (hasCargoWorkspace) pushRelative(root / "Cargo.toml"); + if (fs::exists(crateCargo)) pushRelative(crateCargo); + } + if (lowerTitle.find("compositor") != std::string::npos || + lowerTitle.find("wayland") != std::string::npos || + lowerTitle.find("scaffold") != std::string::npos || + lowerTitle.find("runtime") != std::string::npos) { + if (fs::exists(crateMain)) pushRelative(crateMain); + pushRelative(crateCompositor); + } + if (lowerTitle.find("focus") != std::string::npos || + lowerTitle.find("stream") != std::string::npos || + lowerTitle.find("binding") != std::string::npos || + lowerTitle.find("coupled") != std::string::npos || + lowerTitle.find("split") != std::string::npos || + lowerTitle.find("state") != std::string::npos) { + if (fs::exists(crateMain)) pushRelative(crateMain); + pushRelative(crateFocus); + } + if (lowerTitle.find("doc") != std::string::npos || + lowerTitle.find("readme") != std::string::npos || + lowerTitle.find("vision") != std::string::npos) { + if (fs::exists(readme)) pushRelative(readme); + if (fs::exists(vision)) pushRelative(vision); + } + if (lowerTitle.find("verify") != std::string::npos || + lowerTitle.find("build") != std::string::npos || + lowerTitle.find("test") != std::string::npos) { + if (hasCargoWorkspace) pushRelative(root / "Cargo.toml"); + if (fs::exists(crateMain)) pushRelative(crateMain); + } + + json out = json::array(); + for (const auto& file : sortedUnique(targets)) out.push_back(file); + return out; + } + static json inferResourceLocks(const AnnotatedTaskitem& task) { std::vector locks; const std::string title = intakeToLowerCopy(task.base.title); @@ -549,26 +712,229 @@ return out; } + static bool endsWith(const std::string& value, const std::string& suffix) { + return value.size() >= suffix.size() && + value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; + } + + static bool isCodeTargetFile(const std::string& path) { + return endsWith(path, ".cpp") || endsWith(path, ".cc") || endsWith(path, ".cxx") || + endsWith(path, ".c") || endsWith(path, ".h") || endsWith(path, ".hpp") || + endsWith(path, ".py") || endsWith(path, ".rs") || endsWith(path, ".ts") || + endsWith(path, ".tsx") || endsWith(path, ".js") || endsWith(path, ".jsx") || + endsWith(path, ".go"); + } + + static bool isSchemaTargetFile(const std::string& path) { + return endsWith(path, ".schema.json") || + (endsWith(path, ".json") && intakeToLowerCopy(path).find("schema") != std::string::npos); + } + + static bool isDocTargetFile(const std::string& path) { + return endsWith(path, ".md") || endsWith(path, ".txt") || endsWith(path, ".rst"); + } + + static std::string pathStem(const std::string& path) { + std::size_t slash = path.find_last_of('/'); + const std::string leaf = slash == std::string::npos ? path : path.substr(slash + 1); + std::size_t dot = leaf.find('.'); + return dot == std::string::npos ? leaf : leaf.substr(0, dot); + } + + static std::string inferVerificationType(const AnnotatedTaskitem& task, const json& targetFiles) { + bool sawCode = false; + bool sawSchema = false; + bool sawDocs = false; + if (targetFiles.is_array()) { + for (const auto& fileJson : targetFiles) { + if (!fileJson.is_string()) continue; + const std::string path = fileJson.get(); + sawCode = sawCode || isCodeTargetFile(path); + sawSchema = sawSchema || isSchemaTargetFile(path); + sawDocs = sawDocs || isDocTargetFile(path); + } + } + if (sawCode) return "unit"; + if (sawSchema) return "schema"; + if (sawDocs) return "docs"; + const std::string lowerTitle = intakeToLowerCopy(task.base.title); + if (lowerTitle.find("test") != std::string::npos) return "unit"; + if (lowerTitle.find("schema") != std::string::npos) return "schema"; + if (lowerTitle.find("doc") != std::string::npos || lowerTitle.find("readme") != std::string::npos) return "docs"; + return "smoke"; + } + + static json inferStepIds(const AnnotatedTaskitem& task, + const json& targetFiles, + const std::string& verificationType) { + std::vector steps = { + "inspect_scope" + }; + const std::string lowerTitle = intakeToLowerCopy(task.base.title + " " + task.base.intent); + if (lowerTitle.find("smithay") != std::string::npos || + lowerTitle.find("dependency") != std::string::npos || + lowerTitle.find("cargo") != std::string::npos) { + appendUnique(&steps, "update_dependencies"); + } else if (verificationType == "docs") { + appendUnique(&steps, "align_docs"); + } else if (verificationType == "unit" || verificationType == "integration") { + appendUnique(&steps, "implement_changes"); + } else { + appendUnique(&steps, "apply_changes"); + } + + if (verificationType == "unit") { + appendUnique(&steps, "run_unit_verification"); + } else if (verificationType == "integration") { + appendUnique(&steps, "run_integration_verification"); + } else if (verificationType == "docs") { + appendUnique(&steps, "review_doc_alignment"); + } else { + appendUnique(&steps, "run_smoke_verification"); + } + + if (targetFiles.is_array() && targetFiles.empty()) { + appendUnique(&steps, "resolve_target_files"); + } + + json out = json::array(); + for (const auto& step : sortedUnique(steps)) out.push_back(step); + return out; + } + + static json inferAcceptanceCommands(const AnnotatedTaskitem& task, + const json& targetFiles, + const std::string& verificationType) { + std::vector commands; + bool sawRust = false; + if (targetFiles.is_array()) { + for (const auto& fileJson : targetFiles) { + if (!fileJson.is_string()) continue; + const std::string path = fileJson.get(); + if (endsWith(path, ".rs") || endsWith(path, "Cargo.toml")) sawRust = true; + } + } + + if (sawRust) { + appendUnique(&commands, "cargo check"); + if (verificationType == "unit" || verificationType == "integration") { + appendUnique(&commands, "cargo test -p whimpwm"); + } + } else if (verificationType == "schema") { + appendUnique(&commands, "python3 -m json.tool"); + } else if (verificationType == "docs") { + appendUnique(&commands, "whetstone_validate_taskitem"); + } + + appendUnique(&commands, "whetstone_validate_taskitem"); + json out = json::array(); + for (const auto& command : sortedUnique(commands)) out.push_back(command); + return out; + } + + static void appendDerivedTestFile(std::vector* out, const std::string& file) { + if (!out || file.empty()) return; + const std::string stem = pathStem(file); + if (stem.empty()) return; + + if (endsWith(file, ".py")) { + appendUnique(out, "tests/test_" + stem + ".py"); + return; + } + if (endsWith(file, ".schema.json")) { + appendUnique(out, "tests/test_" + stem + "_schema.py"); + return; + } + if (endsWith(file, ".json")) { + appendUnique(out, "tests/test_" + stem + ".py"); + return; + } + if (endsWith(file, ".cpp") || endsWith(file, ".cc") || endsWith(file, ".cxx") || + endsWith(file, ".c") || endsWith(file, ".h") || endsWith(file, ".hpp")) { + std::string testPath = file; + std::size_t srcPos = testPath.find("/src/"); + if (srcPos != std::string::npos) { + testPath.replace(srcPos, 5, "/tests/"); + } else { + std::size_t slash = testPath.find_last_of('/'); + if (slash != std::string::npos) { + testPath = testPath.substr(0, slash + 1) + "tests/" + testPath.substr(slash + 1); + } else { + testPath = "tests/" + testPath; + } + } + std::size_t dot = testPath.find_last_of('.'); + if (dot != std::string::npos) { + testPath = testPath.substr(0, dot) + "_test.cpp"; + } else { + testPath += "_test.cpp"; + } + appendUnique(out, testPath); + return; + } + if (endsWith(file, ".rs")) { + appendUnique(out, "tests/" + stem + "_test.rs"); + return; + } + if (endsWith(file, ".go")) { + appendUnique(out, "tests/" + stem + "_test.go"); + return; + } + if (endsWith(file, ".ts") || endsWith(file, ".tsx") || endsWith(file, ".js") || endsWith(file, ".jsx")) { + appendUnique(out, "tests/" + stem + ".test.ts"); + } + } + + static json inferTestFiles(const AnnotatedTaskitem& task, + const json& targetFiles, + const std::string& verificationType) { + std::vector out; + if (verificationType != "unit" && verificationType != "integration") { + return json::array(); + } + if (targetFiles.is_array()) { + for (const auto& fileJson : targetFiles) { + if (!fileJson.is_string()) continue; + appendDerivedTestFile(&out, fileJson.get()); + } + } + if (out.empty()) { + appendUnique(&out, "tests/test_" + task.base.taskId + ".py"); + } + json jsonOut = json::array(); + for (const auto& file : sortedUnique(out)) jsonOut.push_back(file); + return jsonOut; + } + static int executionSpecificityScore(const json& executionContract) { const int stepCount = executionContract.value("stepIds", json::array()).size(); const int fileCount = executionContract.value("targetFiles", json::array()).size(); const int toolCount = executionContract.value("requiredTools", json::array()).size(); const int commandCount = executionContract.value("acceptanceCommands", json::array()).size(); + const int testFileCount = executionContract.value("testFiles", json::array()).size(); + const bool hasVerificationType = executionContract.contains("verificationType") && + executionContract["verificationType"].is_string() && + !executionContract["verificationType"].get().empty(); int score = 0; - score += std::min(30, stepCount * 10); - score += std::min(25, fileCount * 5); - score += std::min(20, toolCount * 5); - score += std::min(25, commandCount * 5); + score += std::min(25, stepCount * 10); + score += std::min(20, fileCount * 5); + score += std::min(15, toolCount * 5); + score += std::min(15, commandCount * 5); + score += std::min(15, testFileCount * 5); + if (hasVerificationType) score += 10; return std::clamp(score, 0, 100); } static json buildExecutionContract(const AnnotatedTaskitem& task, const json& hints, - bool strictExecutionContract) { + bool strictExecutionContract, + const std::string& workspaceRoot) { json stepIds = hints.value("stepIds", json::array()); json targetFiles = hints.value("targetFiles", json::array()); json requiredTools = hints.value("requiredTools", json::array()); json acceptanceCommands = hints.value("acceptanceCommands", json::array()); + std::string verificationType = inferVerificationType(task, targetFiles); + json testFiles = inferTestFiles(task, targetFiles, verificationType); if (strictExecutionContract) { if (!requiredTools.is_array() || requiredTools.empty()) { @@ -579,26 +945,46 @@ }); } if (!acceptanceCommands.is_array() || acceptanceCommands.empty()) { - acceptanceCommands = json::array({ - "step615_test", - "step616_test", - "step682_test" - }); + if (verificationType == "unit" || verificationType == "integration") { + acceptanceCommands = json::array({"pytest", "whetstone_validate_taskitem"}); + } else if (verificationType == "schema") { + acceptanceCommands = json::array({"python3 -m json.tool", "whetstone_validate_taskitem"}); + } else { + acceptanceCommands = json::array({"whetstone_validate_taskitem"}); + } } if (!targetFiles.is_array() || targetFiles.empty()) { json crossProjectTargets = inferCrossProjectTargetsFromHints(hints); if (crossProjectTargets.is_array() && !crossProjectTargets.empty()) { targetFiles = crossProjectTargets; } else { - targetFiles = json::array({ - "editor/src/mcp/RegisterArchitectIntakeTools.h", - "editor/src/mcp/RegisterValidationTools.h", - "tools/mcp/run_sprint_taskitem_pipeline.sh" - }); + json taskTargets = inferWorkspaceTaskTargets(task, workspaceRoot); + if (taskTargets.is_array() && !taskTargets.empty()) { + targetFiles = taskTargets; + } else { + json workspaceTargets = inferWorkspaceFallbackTargets(workspaceRoot); + if (workspaceTargets.is_array() && !workspaceTargets.empty()) { + targetFiles = workspaceTargets; + } else { + targetFiles = json::array({ + "editor/src/mcp/RegisterArchitectIntakeTools.h", + "editor/src/mcp/RegisterValidationTools.h", + "tools/mcp/run_sprint_taskitem_pipeline.sh" + }); + } + } } } + verificationType = inferVerificationType(task, targetFiles); if (!stepIds.is_array() || stepIds.empty()) { - stepIds = json::array({"1939", "1940", "1941"}); + stepIds = inferStepIds(task, targetFiles, verificationType); + } + if (!acceptanceCommands.is_array() || acceptanceCommands.empty()) { + acceptanceCommands = inferAcceptanceCommands(task, targetFiles, verificationType); + } + if ((!testFiles.is_array() || testFiles.empty()) && + (verificationType == "unit" || verificationType == "integration")) { + testFiles = inferTestFiles(task, targetFiles, verificationType); } } @@ -607,6 +993,8 @@ {"targetFiles", targetFiles}, {"requiredTools", requiredTools}, {"acceptanceCommands", acceptanceCommands}, + {"testFiles", testFiles}, + {"verificationType", verificationType}, {"evidenceArtifacts", json::array({ "logs/taskitem_runs/" + task.base.taskId + "/trace.jsonl", "logs/taskitem_runs/" + task.base.taskId + "/gate_summary.json" @@ -743,6 +1131,7 @@ } const bool strictExecutionContract = args.value("strictExecutionContract", false); + const std::string workspaceRoot = args.value("workspace", ""); const json executionHints = collectExecutionHints(normalized.requirements); int escalateCount = 0; int missingContractCount = 0; @@ -753,7 +1142,9 @@ json taskJson = { {"taskId", task.base.taskId}, {"title", task.base.title}, + {"intent", task.base.intent}, {"milestoneId", task.base.milestoneId}, + {"requirementIds", task.base.requirementIds}, {"dependencyTaskIds", task.base.dependencyTaskIds}, {"prerequisiteOps", task.base.prerequisiteOps}, {"queueReady", task.base.queueReady}, @@ -765,7 +1156,7 @@ json taskExecutionHints = executionHints; taskExecutionHints["targetFiles"] = selectTaskScopedTargetFiles(task, normalized.requirements, executionHints.value("targetFiles", json::array())); - json contract = buildExecutionContract(task, taskExecutionHints, strictExecutionContract); + json contract = buildExecutionContract(task, taskExecutionHints, strictExecutionContract, workspaceRoot); if (libraryDispatchEnabled) { std::string dispatchText = task.base.title; if (!libraryDispatchContext.empty()) { @@ -908,6 +1299,7 @@ NormalizedRequirement requirement; requirement.requirementId = requirementJson.value("requirementId", ""); requirement.normalizedText = requirementJson.value("normalizedText", ""); + requirement.sourceText = requirementJson.value("sourceText", requirement.normalizedText); requirement.anchor = requirementJson.value("anchor", ""); requirement.sourceLine = requirementJson.value("sourceLine", 0); requirement.ambiguous = requirementJson.value("ambiguous", false); @@ -992,7 +1384,9 @@ out.push_back({ {"taskId", task.base.taskId}, {"title", task.base.title}, + {"intent", task.base.intent}, {"milestoneId", task.base.milestoneId}, + {"requirementIds", task.base.requirementIds}, {"dependencyTaskIds", task.base.dependencyTaskIds}, {"prerequisiteOps", task.base.prerequisiteOps}, {"queueReady", task.base.queueReady}, @@ -1045,7 +1439,17 @@ const bool hasFiles = contract.contains("targetFiles") && contract["targetFiles"].is_array() && !contract["targetFiles"].empty(); const bool hasTools = contract.contains("requiredTools") && contract["requiredTools"].is_array() && !contract["requiredTools"].empty(); const bool hasCommands = contract.contains("acceptanceCommands") && contract["acceptanceCommands"].is_array() && !contract["acceptanceCommands"].empty(); - if (!(hasSteps && hasFiles && hasTools && hasCommands)) ++executionContractMissingCount; + const bool hasVerificationType = contract.contains("verificationType") && + contract["verificationType"].is_string() && + !contract["verificationType"].get().empty(); + const std::string verificationType = contract.value("verificationType", ""); + const bool requiresTestFiles = verificationType == "unit" || verificationType == "integration"; + const bool hasTestFiles = contract.contains("testFiles") && contract["testFiles"].is_array() && + !contract["testFiles"].empty(); + if (!(hasSteps && hasFiles && hasTools && hasCommands && hasVerificationType && + (!requiresTestFiles || hasTestFiles))) { + ++executionContractMissingCount; + } if (contract.value("executionSpecificityScore", 0) < 60) ++executionSpecificityLowCount; } } @@ -1191,7 +1595,9 @@ AnnotatedTaskitem task; task.base.taskId = taskJson.value("taskId", ""); task.base.title = taskJson.value("title", ""); + task.base.intent = taskJson.value("intent", ""); task.base.milestoneId = taskJson.value("milestoneId", "milestone-1"); + task.base.requirementIds = taskJson.value("requirementIds", std::vector{}); task.base.dependencyTaskIds = taskJson.value("dependencyTaskIds", std::vector{}); task.base.prerequisiteOps = taskJson.value("prerequisiteOps", std::vector{}); task.base.queueReady = taskJson.value("queueReady", true); @@ -1240,6 +1646,7 @@ NormalizedRequirement requirement; requirement.requirementId = requirementJson.value("requirementId", ""); requirement.normalizedText = requirementJson.value("normalizedText", ""); + requirement.sourceText = requirementJson.value("sourceText", requirement.normalizedText); requirement.anchor = requirementJson.value("anchor", ""); requirement.sourceLine = requirementJson.value("sourceLine", 0); requirement.ambiguous = requirementJson.value("ambiguous", false); diff --git a/editor/src/mcp/RegisterOnboardingAndAllTools.h b/editor/src/mcp/RegisterOnboardingAndAllTools.h index ed28f46..b742ad2 100644 --- a/editor/src/mcp/RegisterOnboardingAndAllTools.h +++ b/editor/src/mcp/RegisterOnboardingAndAllTools.h @@ -34,6 +34,7 @@ registerWorkflowTools(); registerWorkflowExecutionTools(); registerRoutingTools(); + registerTaskitemPoolTools(); registerOrchestratorTools(); registerReviewTools(); registerArchitectIntakeTools(); diff --git a/editor/src/mcp/RegisterTaskitemPoolTools.h b/editor/src/mcp/RegisterTaskitemPoolTools.h new file mode 100644 index 0000000..bd083b5 --- /dev/null +++ b/editor/src/mcp/RegisterTaskitemPoolTools.h @@ -0,0 +1,72 @@ + void registerTaskitemPoolTools() { + tools_.push_back({"whetstone_import_taskitem_pool", + "Import generated taskitems into a project-scoped pool stored under .whetstone.", + {{"type", "object"}, {"properties", { + {"projectName", {{"type", "string"}}}, + {"taskitemsPath", {{"type", "string"}, + {"description", "Path to a generated taskitems JSON file."}}}, + {"taskitems", {{"type", "array"}, + {"description", "Taskitem objects to import directly."}}}, + {"replace", {{"type", "boolean"}}}, + {"sourceLabel", {{"type", "string"}}} + }}} + }); + toolHandlers_["whetstone_import_taskitem_pool"] = + [this](const json& args) { + return callWhetstone("importTaskitemPool", args); + }; + + tools_.push_back({"whetstone_list_taskitem_pool", + "List taskitems from the project-scoped taskitem pool.", + {{"type", "object"}, {"properties", { + {"projectName", {{"type", "string"}}}, + {"status", {{"type", "string"}, + {"description", "Optional status filter: ready, blocked, claimed, complete."}}} + }}} + }); + toolHandlers_["whetstone_list_taskitem_pool"] = + [this](const json& args) { + return callWhetstone("listTaskitemPool", args); + }; + + tools_.push_back({"whetstone_claim_taskitem", + "Claim a pooled taskitem for an external agent or worker.", + {{"type", "object"}, {"properties", { + {"projectName", {{"type", "string"}}}, + {"itemId", {{"type", "string"}}}, + {"assignee", {{"type", "string"}}}, + {"workerType", {{"type", "string"}}} + }}, {"required", json::array({"itemId", "assignee"})}} + }); + toolHandlers_["whetstone_claim_taskitem"] = + [this](const json& args) { + return callWhetstone("claimTaskitem", args); + }; + + tools_.push_back({"whetstone_release_taskitem", + "Release a claimed pooled taskitem back to the project pool.", + {{"type", "object"}, {"properties", { + {"projectName", {{"type", "string"}}}, + {"itemId", {{"type", "string"}}}, + {"assignee", {{"type", "string"}}}, + {"reason", {{"type", "string"}}} + }}, {"required", json::array({"itemId"})}} + }); + toolHandlers_["whetstone_release_taskitem"] = + [this](const json& args) { + return callWhetstone("releaseTaskitem", args); + }; + + tools_.push_back({"whetstone_complete_pooled_taskitem", + "Mark a pooled taskitem complete with optional result payload.", + {{"type", "object"}, {"properties", { + {"projectName", {{"type", "string"}}}, + {"itemId", {{"type", "string"}}}, + {"result", {{"type", "object"}}} + }}, {"required", json::array({"itemId"})}} + }); + toolHandlers_["whetstone_complete_pooled_taskitem"] = + [this](const json& args) { + return callWhetstone("completePooledTaskitem", args); + }; + } diff --git a/editor/src/mcp/RegisterValidationTools.h b/editor/src/mcp/RegisterValidationTools.h index 2e9cfd8..e3c2388 100644 --- a/editor/src/mcp/RegisterValidationTools.h +++ b/editor/src/mcp/RegisterValidationTools.h @@ -50,7 +50,9 @@ static std::string classifyValidationIssueGapClass(const std::string& issue) { if (issue.find("executionSpecificityScore") != std::string::npos || issue.find("execution contract") != std::string::npos || - issue.find("reasons has fewer than 3") != std::string::npos) { + issue.find("verificationType") != std::string::npos || + issue.find("testFiles") != std::string::npos || + issue.find("reasons are minimal") != std::string::npos) { return "under_constrained_taskitem"; } if (issue.find("cross_project_target_mismatch") != std::string::npos) { @@ -63,10 +65,10 @@ issue.find("prerequisiteOps missing") != std::string::npos) { return "environment_or_dependency_gap"; } - if (issue.find("dependencyTaskIds not empty") != std::string::npos) { + if (issue.find("dependencyTaskIds include multiple upstream tasks") != std::string::npos) { return "task_dependency_or_planning_gap"; } - return "generator_capability_gap"; + return "under_constrained_taskitem"; } static json recommendedToolsForValidationGap(const std::string& gapClass) { @@ -241,6 +243,23 @@ if (itemJson.contains("execution_contract") && itemJson["execution_contract"].is_object()) { const json& contract = itemJson["execution_contract"]; item.executionSpecificityScore = contract.value("executionSpecificityScore", 0); + item.hasTestFiles = contract.contains("testFiles") && + contract["testFiles"].is_array() && + !contract["testFiles"].empty(); + item.hasVerificationType = contract.contains("verificationType") && + contract["verificationType"].is_string() && + !contract["verificationType"].get().empty(); + item.verificationType = contract.value("verificationType", ""); + } else if (itemJson.contains("executionContract") && itemJson["executionContract"].is_object()) { + const json& contract = itemJson["executionContract"]; + item.executionSpecificityScore = contract.value("executionSpecificityScore", 0); + item.hasTestFiles = contract.contains("testFiles") && + contract["testFiles"].is_array() && + !contract["testFiles"].empty(); + item.hasVerificationType = contract.contains("verificationType") && + contract["verificationType"].is_string() && + !contract["verificationType"].get().empty(); + item.verificationType = contract.value("verificationType", ""); } else { item.executionSpecificityScore = itemJson.value("execution_specificity_score", 0); } diff --git a/tools/mcp/run_sprint_taskitem_pipeline.sh b/tools/mcp/run_sprint_taskitem_pipeline.sh index b32144d..4410b54 100755 --- a/tools/mcp/run_sprint_taskitem_pipeline.sh +++ b/tools/mcp/run_sprint_taskitem_pipeline.sh @@ -442,9 +442,9 @@ else NATIVE_RAW_TEMPLATE_CONTROL_PACK_JSON='{"enabled":false}' fi CONFLICTS="$(printf '%s' "$INTAKE_JSON" | jq '.conflicts // []')" -GEN_ARGS="$(jq -nc --argjson nr "$NORMALIZED_REQS" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" \ +GEN_ARGS="$(jq -nc --argjson nr "$NORMALIZED_REQS" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" --arg ws "$WORKSPACE" \ --arg mode "$LIBRARY_POLICY_MODE" --arg expansion "$LIBRARY_EXPANSION_MODE" --argjson max_additional "$LIBRARY_MAX_ADDITIONAL" \ - '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1")}')" + '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1"),workspace:$ws}')" GEN_RESP_RAW="$(call_tool "whetstone_generate_taskitems" "$GEN_ARGS")" printf '%s\n' "$GEN_RESP_RAW" > "$OUT_DIR/02_generate_taskitems_raw.ndjson.json" GEN_JSON="$(extract_tool_text_json "$GEN_RESP_RAW")" @@ -467,9 +467,9 @@ if [[ "$NATIVE_DECOMP_RETRY" == "1" && "$native_task_count_initial" -lt "$NATIVE ambiguous: false }] ')" - RETRY_GEN_ARGS="$(jq -nc --argjson nr "$RETRY_REQS" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" \ + RETRY_GEN_ARGS="$(jq -nc --argjson nr "$RETRY_REQS" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" --arg ws "$WORKSPACE" \ --arg mode "$LIBRARY_POLICY_MODE" --arg expansion "$LIBRARY_EXPANSION_MODE" --argjson max_additional "$LIBRARY_MAX_ADDITIONAL" \ - '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1")}')" + '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1"),workspace:$ws}')" RETRY_GEN_RESP_RAW="$(call_tool "whetstone_generate_taskitems" "$RETRY_GEN_ARGS")" printf '%s\n' "$RETRY_GEN_RESP_RAW" > "$OUT_DIR/02aa_generate_taskitems_retry_raw.ndjson.json" RETRY_GEN_JSON="$(extract_tool_text_json "$RETRY_GEN_RESP_RAW")" @@ -581,9 +581,9 @@ if [[ "$NATIVE_RAW_CANDIDATE_SEARCH" == "1" ]]; then while [[ "$variant" -lt "$max_variants" && "$idx" -lt "$variant_total" ]]; do req_bundle="$(printf '%s' "$VARIANTS_JSON" | jq ".variants[$idx]")" cand_reqs="$(jq -nc --argjson base "$NORMALIZED_REQS" --argjson bundle "$req_bundle" '$base + $bundle')" - cand_args="$(jq -nc --argjson nr "$cand_reqs" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" \ + cand_args="$(jq -nc --argjson nr "$cand_reqs" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" --arg ws "$WORKSPACE" \ --arg mode "$LIBRARY_POLICY_MODE" --arg expansion "$LIBRARY_EXPANSION_MODE" --argjson max_additional "$LIBRARY_MAX_ADDITIONAL" \ - '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1")}')" + '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1"),workspace:$ws}')" cand_raw="$(call_tool "whetstone_generate_taskitems" "$cand_args")" printf '%s\n' "$cand_raw" > "$OUT_DIR/02ae_candidate_${variant}_raw.ndjson.json" cand_json="$(extract_tool_text_json "$cand_raw")" @@ -646,9 +646,9 @@ if [[ "$NATIVE_RAW_CANDIDATE_SEARCH" == "1" ]]; then --out-report "$OUT_DIR/02af_raw_adaptive_retry_requirements_report.json" >/dev/null retry_top_gap_reqs="$(cat "$OUT_DIR/02af_raw_adaptive_retry_requirements.json")" retry_cand_reqs="$(jq -nc --argjson base "$NORMALIZED_REQS" --argjson extra "$retry_top_gap_reqs" '$base + $extra')" - retry_cand_args="$(jq -nc --argjson nr "$retry_cand_reqs" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" \ + retry_cand_args="$(jq -nc --argjson nr "$retry_cand_reqs" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" --arg ws "$WORKSPACE" \ --arg mode "$LIBRARY_POLICY_MODE" --arg expansion "$LIBRARY_EXPANSION_MODE" --argjson max_additional "$LIBRARY_MAX_ADDITIONAL" \ - '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1")}')" + '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1"),workspace:$ws}')" retry_raw="$(call_tool "whetstone_generate_taskitems" "$retry_cand_args")" printf '%s\n' "$retry_raw" > "$OUT_DIR/02af_raw_adaptive_retry_raw.ndjson.json" retry_json="$(extract_tool_text_json "$retry_raw")" @@ -835,9 +835,9 @@ if [[ "$NATIVE_MULTISHOT_DECOMP" == "1" ]]; then '[($prof.profiles[]? | select(.id == $pid) | .required_execution_contract[]?) // empty]')" shot_reqs="$(jq -nc --argjson base "$NORMALIZED_REQS" --argjson req "$req_i" '$base + [$req]')" - shot_args="$(jq -nc --argjson nr "$shot_reqs" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" \ + shot_args="$(jq -nc --argjson nr "$shot_reqs" --argjson cf "$CONFLICTS" --argjson libs "$AVAILABLE_LIBRARIES_JSON" --arg strict "$STRICT_EXECUTION_CONTRACT" --arg ws "$WORKSPACE" \ --arg mode "$LIBRARY_POLICY_MODE" --arg expansion "$LIBRARY_EXPANSION_MODE" --argjson max_additional "$LIBRARY_MAX_ADDITIONAL" \ - '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1")}')" + '{normalizedRequirements:$nr,conflicts:$cf,availableLibraries:$libs,libraryPolicy:{mode:$mode,expansionMode:$expansion,maxAdditionalLibraries:$max_additional},strictExecutionContract:($strict == "1"),workspace:$ws}')" shot_raw="$(call_tool "whetstone_generate_taskitems" "$shot_args")" printf '%s\n' "$shot_raw" > "$OUT_DIR/02ac_generate_taskitems_multishot_${i}_raw.ndjson.json" shot_json="$(extract_tool_text_json "$shot_raw")"