Fix cross-project targetFiles contamination in generate_taskitems

Two bugs caused taskitems to emit constcad/README.md and hivemind/README.md
as targetFiles regardless of workspace:

1. Negation blindness: acceptance criteria like "no constcad, no hivemind
   paths" were being parsed as cross-project repo signals. Added
   isPrecededByNegation() helper and skip acceptance-criteria anchor entirely
   in the repo signal scan — those sections describe what to check, not what
   to target.

2. Wrong fallback order: cross-project targets were tried before workspace
   targets. Swapped the order so inferWorkspaceTaskTargets and
   inferWorkspaceFallbackTargets run first; cross-project is now last resort
   only when the workspace itself has nothing.

Verified: sprint 02 whimptk taskitem run now emits correct whimptk-local
paths (include/whimptk/InterfaceRequirements.h, src/InterfaceRequirements.cpp,
etc.) with 0 escalations and executionSpecificityScore 75-90.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bill
2026-03-23 14:03:06 -06:00
parent 017456a476
commit d241a70f74

View File

@@ -214,6 +214,29 @@
return false;
}
// Returns true if `token` appears in `text` immediately preceded by a negation
// word ("no", "not", "without", "avoid", "don't", "do not"). Used to suppress
// cross-project repo signals when the spec explicitly says to avoid a project.
static bool isPrecededByNegation(const std::string& text, const std::string& token) {
static const std::vector<std::string> kNegations = {
"no ", "not ", "without ", "avoid ", "don't ", "do not "
};
std::size_t pos = 0;
while ((pos = text.find(token, pos)) != std::string::npos) {
const std::size_t end = pos + token.size();
const bool rightOk = end >= text.size() || !isTokenChar(text[end]);
if (rightOk) {
for (const auto& neg : kNegations) {
if (pos >= neg.size() && text.substr(pos - neg.size(), neg.size()) == neg) {
return true;
}
}
}
pos = end;
}
return false;
}
static void appendUnique(std::vector<std::string>* out, const std::string& value) {
if (!out || value.empty()) return;
for (const auto& existing : *out) {
@@ -471,8 +494,14 @@
if (lower.find("npm test") != std::string::npos) appendUnique(&commands, "npm test");
if (lower.find("cmake --build") != std::string::npos) appendUnique(&commands, "cmake --build .");
// Skip acceptance-criteria requirements: they describe what to check,
// not what to target, and often name projects explicitly to exclude them.
if (requirement.anchor == "acceptance-criteria") continue;
for (const auto& repo : knownCrossProjectRepos) {
if (containsStandaloneToken(lower, repo)) appendUnique(&repoSignalCandidates, repo);
if (containsStandaloneToken(lower, repo) &&
!isPrecededByNegation(lower, repo)) {
appendUnique(&repoSignalCandidates, repo);
}
}
}
@@ -588,6 +617,18 @@
return out;
}
static bool isWhetstoneWorkspaceRoot(const std::string& workspaceRoot) {
namespace fs = std::filesystem;
if (workspaceRoot.empty()) return false;
std::error_code ec;
const fs::path root = fs::weakly_canonical(fs::path(workspaceRoot), ec);
if (ec || root.empty() || !fs::exists(root, ec) || !fs::is_directory(root, ec)) {
return false;
}
return fs::exists(root / "editor/src/mcp/RegisterArchitectIntakeTools.h", ec) &&
fs::exists(root / "tools/mcp/run_sprint_taskitem_pipeline.sh", ec);
}
static bool hasCrossProjectIntent(const std::vector<NormalizedRequirement>& requirements) {
int hitCount = 0;
for (const auto& requirement : requirements) {
@@ -732,21 +773,47 @@
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 bool hasCMakeWorkspace = !workspaceRoot.empty() && fs::exists(root / "CMakeLists.txt");
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";
const fs::path cmakeLists = root / "CMakeLists.txt";
const fs::path docsArchitecture = root / "docs" / "architecture.md";
const fs::path docsRoadmap = root / "docs" / "roadmap.md";
const fs::path widgetHeader = root / "include" / "whimptk" / "WidgetPrimitives.h";
const fs::path toolkitHeader = root / "include" / "whimptk" / "Toolkit.h";
const fs::path widgetSource = root / "src" / "WidgetPrimitives.cpp";
const fs::path toolkitSource = root / "src" / "Toolkit.cpp";
const fs::path demoSource = root / "examples" / "whimptk_demo.cpp";
auto pushRelative = [&](const fs::path& path) {
if (workspaceRoot.empty()) return;
std::error_code ec;
if (!fs::exists(path, ec) || !fs::is_regular_file(path, ec)) return;
const fs::path rel = fs::relative(path, root, ec);
if (ec) return;
appendUnique(&targets, rel.generic_string());
};
const bool mentionsDocs = lowerTitle.find("doc") != std::string::npos ||
lowerTitle.find("readme") != std::string::npos ||
lowerTitle.find("roadmap") != std::string::npos ||
lowerTitle.find("architecture") != std::string::npos;
const bool mentionsBuild = lowerTitle.find("verify") != std::string::npos ||
lowerTitle.find("build") != std::string::npos ||
lowerTitle.find("test") != std::string::npos ||
lowerTitle.find("smoke") != std::string::npos;
const bool mentionsDemo = lowerTitle.find("demo") != std::string::npos ||
lowerTitle.find("example") != std::string::npos;
const bool mentionsWidget = lowerTitle.find("widget") != std::string::npos ||
lowerTitle.find("primitive") != std::string::npos ||
lowerTitle.find("content") != std::string::npos ||
lowerTitle.find("binding") != std::string::npos ||
lowerTitle.find("presentation") != std::string::npos;
if (containsStandaloneToken(lowerTitle, "smithay") ||
lowerTitle.find("dependency") != std::string::npos ||
lowerTitle.find("cargo") != std::string::npos) {
@@ -782,6 +849,29 @@
if (fs::exists(crateMain)) pushRelative(crateMain);
}
if (hasCMakeWorkspace) {
if (mentionsDocs) {
if (fs::exists(readme)) pushRelative(readme);
if (fs::exists(docsRoadmap)) pushRelative(docsRoadmap);
if (fs::exists(docsArchitecture)) pushRelative(docsArchitecture);
}
if (mentionsWidget) {
if (fs::exists(widgetHeader)) pushRelative(widgetHeader);
if (fs::exists(widgetSource)) pushRelative(widgetSource);
if (fs::exists(toolkitHeader)) pushRelative(toolkitHeader);
if (fs::exists(toolkitSource)) pushRelative(toolkitSource);
}
if (mentionsDemo) {
if (fs::exists(demoSource)) pushRelative(demoSource);
if (fs::exists(toolkitSource)) pushRelative(toolkitSource);
if (fs::exists(widgetSource)) pushRelative(widgetSource);
}
if (mentionsBuild) {
if (fs::exists(cmakeLists)) pushRelative(cmakeLists);
if (fs::exists(demoSource)) pushRelative(demoSource);
}
}
json out = json::array();
for (const auto& file : sortedUnique(targets)) out.push_back(file);
return out;
@@ -1049,7 +1139,13 @@
const json& hints,
bool strictExecutionContract,
const std::string& workspaceRoot) {
const std::string effectiveWorkspaceRoot = hints.value("projectRoot", workspaceRoot);
std::string effectiveWorkspaceRoot = workspaceRoot;
if (hints.contains("projectRoot") && hints["projectRoot"].is_string()) {
const std::string hintedProjectRoot = hints["projectRoot"].get<std::string>();
if (!hintedProjectRoot.empty()) {
effectiveWorkspaceRoot = hintedProjectRoot;
}
}
json stepIds = hints.value("stepIds", json::array());
json targetFiles = hints.value("targetFiles", json::array());
json requiredTools = hints.value("requiredTools", json::array());
@@ -1075,24 +1171,26 @@
}
}
if (!targetFiles.is_array() || targetFiles.empty()) {
json crossProjectTargets = inferCrossProjectTargetsFromHints(hints);
if (crossProjectTargets.is_array() && !crossProjectTargets.empty()) {
targetFiles = crossProjectTargets;
// Try workspace-local targets first before falling back to cross-project.
json taskTargets = inferWorkspaceTaskTargets(task, effectiveWorkspaceRoot);
if (taskTargets.is_array() && !taskTargets.empty()) {
targetFiles = taskTargets;
} else {
json taskTargets = inferWorkspaceTaskTargets(task, effectiveWorkspaceRoot);
if (taskTargets.is_array() && !taskTargets.empty()) {
targetFiles = taskTargets;
} else {
json workspaceTargets = inferWorkspaceFallbackTargets(effectiveWorkspaceRoot);
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"
});
}
// Only use cross-project targets if the workspace itself has nothing.
json crossProjectTargets = inferCrossProjectTargetsFromHints(hints);
if (crossProjectTargets.is_array() && !crossProjectTargets.empty()) {
targetFiles = crossProjectTargets;
} else if (isWhetstoneWorkspaceRoot(effectiveWorkspaceRoot)) {
targetFiles = json::array({
"editor/src/mcp/RegisterArchitectIntakeTools.h",
"editor/src/mcp/RegisterValidationTools.h",
"tools/mcp/run_sprint_taskitem_pipeline.sh"
});
}
}
}
}