foundry-mcp 0.3.3__py3-none-any.whl → 0.8.10__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foundry_mcp/__init__.py +7 -1
- foundry_mcp/cli/__init__.py +0 -13
- foundry_mcp/cli/commands/plan.py +10 -3
- foundry_mcp/cli/commands/review.py +19 -4
- foundry_mcp/cli/commands/session.py +1 -8
- foundry_mcp/cli/commands/specs.py +38 -208
- foundry_mcp/cli/context.py +39 -0
- foundry_mcp/cli/output.py +3 -3
- foundry_mcp/config.py +615 -11
- foundry_mcp/core/ai_consultation.py +146 -9
- foundry_mcp/core/batch_operations.py +1196 -0
- foundry_mcp/core/discovery.py +7 -7
- foundry_mcp/core/error_store.py +2 -2
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/llm_config.py +28 -2
- foundry_mcp/core/metrics_store.py +2 -2
- foundry_mcp/core/naming.py +25 -2
- foundry_mcp/core/progress.py +70 -0
- foundry_mcp/core/prometheus.py +0 -13
- foundry_mcp/core/prompts/fidelity_review.py +149 -4
- foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
- foundry_mcp/core/prompts/plan_review.py +5 -1
- foundry_mcp/core/providers/__init__.py +12 -0
- foundry_mcp/core/providers/base.py +39 -0
- foundry_mcp/core/providers/claude.py +51 -48
- foundry_mcp/core/providers/codex.py +70 -60
- foundry_mcp/core/providers/cursor_agent.py +25 -47
- foundry_mcp/core/providers/detectors.py +34 -7
- foundry_mcp/core/providers/gemini.py +69 -58
- foundry_mcp/core/providers/opencode.py +101 -47
- foundry_mcp/core/providers/package-lock.json +4 -4
- foundry_mcp/core/providers/package.json +1 -1
- foundry_mcp/core/providers/validation.py +128 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1220 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4020 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/responses.py +690 -0
- foundry_mcp/core/spec.py +2439 -236
- foundry_mcp/core/task.py +1205 -31
- foundry_mcp/core/testing.py +512 -123
- foundry_mcp/core/validation.py +319 -43
- foundry_mcp/dashboard/components/charts.py +0 -57
- foundry_mcp/dashboard/launcher.py +11 -0
- foundry_mcp/dashboard/views/metrics.py +25 -35
- foundry_mcp/dashboard/views/overview.py +1 -65
- foundry_mcp/resources/specs.py +25 -25
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +33 -5
- foundry_mcp/server.py +0 -14
- foundry_mcp/tools/unified/__init__.py +39 -18
- foundry_mcp/tools/unified/authoring.py +2371 -248
- foundry_mcp/tools/unified/documentation_helpers.py +69 -6
- foundry_mcp/tools/unified/environment.py +434 -32
- foundry_mcp/tools/unified/error.py +18 -1
- foundry_mcp/tools/unified/lifecycle.py +8 -0
- foundry_mcp/tools/unified/plan.py +133 -2
- foundry_mcp/tools/unified/provider.py +0 -40
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +374 -17
- foundry_mcp/tools/unified/review_helpers.py +16 -1
- foundry_mcp/tools/unified/server.py +9 -24
- foundry_mcp/tools/unified/spec.py +367 -0
- foundry_mcp/tools/unified/task.py +1664 -30
- foundry_mcp/tools/unified/test.py +69 -8
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
- foundry_mcp-0.8.10.dist-info/RECORD +153 -0
- foundry_mcp/cli/flags.py +0 -266
- foundry_mcp/core/feature_flags.py +0 -592
- foundry_mcp-0.3.3.dist-info/RECORD +0 -135
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
foundry_mcp/core/validation.py
CHANGED
|
@@ -8,6 +8,7 @@ Security Note:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
|
+
from difflib import get_close_matches
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, Dict, List, Optional, Callable
|
|
13
14
|
import json
|
|
@@ -109,7 +110,7 @@ class SpecStats:
|
|
|
109
110
|
# Constants
|
|
110
111
|
|
|
111
112
|
STATUS_FIELDS = {"pending", "in_progress", "completed", "blocked"}
|
|
112
|
-
VALID_NODE_TYPES = {"spec", "phase", "group", "task", "subtask", "verify"}
|
|
113
|
+
VALID_NODE_TYPES = {"spec", "phase", "group", "task", "subtask", "verify", "research"}
|
|
113
114
|
VALID_STATUSES = {"pending", "in_progress", "completed", "blocked"}
|
|
114
115
|
VALID_TASK_CATEGORIES = {
|
|
115
116
|
"investigation",
|
|
@@ -118,12 +119,73 @@ VALID_TASK_CATEGORIES = {
|
|
|
118
119
|
"decision",
|
|
119
120
|
"research",
|
|
120
121
|
}
|
|
121
|
-
VALID_VERIFICATION_TYPES = {"
|
|
122
|
+
VALID_VERIFICATION_TYPES = {"run-tests", "fidelity", "manual"}
|
|
123
|
+
|
|
124
|
+
# Legacy to canonical verification type mapping
|
|
125
|
+
VERIFICATION_TYPE_MAPPING = {
|
|
126
|
+
"test": "run-tests",
|
|
127
|
+
"auto": "run-tests",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Research node constants
|
|
131
|
+
VALID_RESEARCH_TYPES = {"chat", "consensus", "thinkdeep", "ideate", "deep-research"}
|
|
132
|
+
VALID_RESEARCH_RESULTS = {"completed", "inconclusive", "blocked", "cancelled"}
|
|
133
|
+
RESEARCH_BLOCKING_MODES = {"none", "soft", "hard"}
|
|
134
|
+
|
|
135
|
+
# Common field name typos/alternatives
|
|
136
|
+
FIELD_NAME_SUGGESTIONS = {
|
|
137
|
+
"category": "task_category",
|
|
138
|
+
"type": "node type or verification_type",
|
|
139
|
+
"desc": "description",
|
|
140
|
+
"details": "description",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _suggest_value(value: str, valid_values: set, n: int = 1) -> Optional[str]:
|
|
145
|
+
"""
|
|
146
|
+
Suggest a close match for an invalid value.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
value: The invalid value provided
|
|
150
|
+
valid_values: Set of valid values to match against
|
|
151
|
+
n: Number of suggestions to return (default 1)
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Suggestion string like "did you mean 'X'?" or None if no close match
|
|
155
|
+
"""
|
|
156
|
+
if not value:
|
|
157
|
+
return None
|
|
158
|
+
matches = get_close_matches(value.lower(), [v.lower() for v in valid_values], n=n, cutoff=0.6)
|
|
159
|
+
if matches:
|
|
160
|
+
# Find the original-case version of the match
|
|
161
|
+
for v in valid_values:
|
|
162
|
+
if v.lower() == matches[0]:
|
|
163
|
+
return f"did you mean '{v}'?"
|
|
164
|
+
return f"did you mean '{matches[0]}'?"
|
|
165
|
+
return None
|
|
122
166
|
|
|
123
167
|
|
|
124
168
|
# Validation functions
|
|
125
169
|
|
|
126
170
|
|
|
171
|
+
def _requires_rich_task_fields(spec_data: Dict[str, Any]) -> bool:
|
|
172
|
+
"""Check if spec requires rich task fields based on explicit complexity metadata."""
|
|
173
|
+
metadata = spec_data.get("metadata", {})
|
|
174
|
+
if not isinstance(metadata, dict):
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
# Only check explicit complexity metadata (template no longer indicates complexity)
|
|
178
|
+
complexity = metadata.get("complexity")
|
|
179
|
+
if isinstance(complexity, str) and complexity.strip().lower() in {
|
|
180
|
+
"medium",
|
|
181
|
+
"complex",
|
|
182
|
+
"high",
|
|
183
|
+
}:
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
127
189
|
def validate_spec_input(
|
|
128
190
|
raw_input: str | bytes,
|
|
129
191
|
*,
|
|
@@ -247,7 +309,7 @@ def validate_spec(spec_data: Dict[str, Any]) -> ValidationResult:
|
|
|
247
309
|
_validate_nodes(hierarchy, result)
|
|
248
310
|
_validate_task_counts(hierarchy, result)
|
|
249
311
|
_validate_dependencies(hierarchy, result)
|
|
250
|
-
_validate_metadata(hierarchy, result)
|
|
312
|
+
_validate_metadata(spec_data, hierarchy, result)
|
|
251
313
|
|
|
252
314
|
# Count diagnostics by severity
|
|
253
315
|
for diag in result.diagnostics:
|
|
@@ -413,6 +475,22 @@ def _validate_structure(spec_data: Dict[str, Any], result: ValidationResult) ->
|
|
|
413
475
|
)
|
|
414
476
|
)
|
|
415
477
|
|
|
478
|
+
if _requires_rich_task_fields(spec_data):
|
|
479
|
+
metadata = spec_data.get("metadata", {})
|
|
480
|
+
mission = metadata.get("mission") if isinstance(metadata, dict) else None
|
|
481
|
+
if not isinstance(mission, str) or not mission.strip():
|
|
482
|
+
result.diagnostics.append(
|
|
483
|
+
Diagnostic(
|
|
484
|
+
code="MISSING_MISSION",
|
|
485
|
+
message="Spec metadata.mission is required when complexity is medium/complex/high",
|
|
486
|
+
severity="error",
|
|
487
|
+
category="metadata",
|
|
488
|
+
location="metadata.mission",
|
|
489
|
+
suggested_fix="Set metadata.mission to a concise goal statement",
|
|
490
|
+
auto_fixable=False,
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
|
|
416
494
|
# Check hierarchy is dict
|
|
417
495
|
hierarchy = spec_data.get("hierarchy")
|
|
418
496
|
if hierarchy is not None and not isinstance(hierarchy, dict):
|
|
@@ -458,6 +536,8 @@ def _validate_hierarchy(hierarchy: Dict[str, Any], result: ValidationResult) ->
|
|
|
458
536
|
severity="error",
|
|
459
537
|
category="hierarchy",
|
|
460
538
|
location="spec-root",
|
|
539
|
+
suggested_fix="Set spec-root parent to null",
|
|
540
|
+
auto_fixable=True,
|
|
461
541
|
)
|
|
462
542
|
)
|
|
463
543
|
|
|
@@ -619,14 +699,18 @@ def _validate_nodes(hierarchy: Dict[str, Any], result: ValidationResult) -> None
|
|
|
619
699
|
# Validate type
|
|
620
700
|
node_type = node.get("type")
|
|
621
701
|
if node_type and node_type not in VALID_NODE_TYPES:
|
|
702
|
+
hint = _suggest_value(node_type, VALID_NODE_TYPES)
|
|
703
|
+
msg = f"Node '{node_id}' has invalid type '{node_type}'"
|
|
704
|
+
if hint:
|
|
705
|
+
msg += f"; {hint}"
|
|
622
706
|
result.diagnostics.append(
|
|
623
707
|
Diagnostic(
|
|
624
708
|
code="INVALID_NODE_TYPE",
|
|
625
|
-
message=
|
|
709
|
+
message=msg,
|
|
626
710
|
severity="error",
|
|
627
711
|
category="node",
|
|
628
712
|
location=node_id,
|
|
629
|
-
suggested_fix="
|
|
713
|
+
suggested_fix=f"Valid types: {', '.join(sorted(VALID_NODE_TYPES))}",
|
|
630
714
|
auto_fixable=True,
|
|
631
715
|
)
|
|
632
716
|
)
|
|
@@ -634,14 +718,18 @@ def _validate_nodes(hierarchy: Dict[str, Any], result: ValidationResult) -> None
|
|
|
634
718
|
# Validate status
|
|
635
719
|
status = node.get("status")
|
|
636
720
|
if status and status not in VALID_STATUSES:
|
|
721
|
+
hint = _suggest_value(status, VALID_STATUSES)
|
|
722
|
+
msg = f"Node '{node_id}' has invalid status '{status}'"
|
|
723
|
+
if hint:
|
|
724
|
+
msg += f"; {hint}"
|
|
637
725
|
result.diagnostics.append(
|
|
638
726
|
Diagnostic(
|
|
639
727
|
code="INVALID_STATUS",
|
|
640
|
-
message=
|
|
728
|
+
message=msg,
|
|
641
729
|
severity="error",
|
|
642
730
|
category="node",
|
|
643
731
|
location=node_id,
|
|
644
|
-
suggested_fix="
|
|
732
|
+
suggested_fix=f"Valid statuses: {', '.join(sorted(VALID_STATUSES))}",
|
|
645
733
|
auto_fixable=True,
|
|
646
734
|
)
|
|
647
735
|
)
|
|
@@ -830,8 +918,27 @@ def _validate_dependencies(hierarchy: Dict[str, Any], result: ValidationResult)
|
|
|
830
918
|
)
|
|
831
919
|
|
|
832
920
|
|
|
833
|
-
def _validate_metadata(
|
|
921
|
+
def _validate_metadata(
|
|
922
|
+
spec_data: Dict[str, Any],
|
|
923
|
+
hierarchy: Dict[str, Any],
|
|
924
|
+
result: ValidationResult,
|
|
925
|
+
) -> None:
|
|
834
926
|
"""Validate type-specific metadata requirements."""
|
|
927
|
+
requires_rich_tasks = _requires_rich_task_fields(spec_data)
|
|
928
|
+
|
|
929
|
+
def _nonempty_string(value: Any) -> bool:
|
|
930
|
+
return isinstance(value, str) and bool(value.strip())
|
|
931
|
+
|
|
932
|
+
def _has_description(metadata: Dict[str, Any]) -> bool:
|
|
933
|
+
if _nonempty_string(metadata.get("description")):
|
|
934
|
+
return True
|
|
935
|
+
details = metadata.get("details")
|
|
936
|
+
if _nonempty_string(details):
|
|
937
|
+
return True
|
|
938
|
+
if isinstance(details, list):
|
|
939
|
+
return any(_nonempty_string(item) for item in details)
|
|
940
|
+
return False
|
|
941
|
+
|
|
835
942
|
for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
|
|
836
943
|
node_type = node.get("type")
|
|
837
944
|
metadata = node.get("metadata", {})
|
|
@@ -860,53 +967,175 @@ def _validate_metadata(hierarchy: Dict[str, Any], result: ValidationResult) -> N
|
|
|
860
967
|
severity="error",
|
|
861
968
|
category="metadata",
|
|
862
969
|
location=node_id,
|
|
863
|
-
suggested_fix="Set verification_type to '
|
|
970
|
+
suggested_fix="Set verification_type to 'run-tests', 'fidelity', or 'manual'",
|
|
864
971
|
auto_fixable=True,
|
|
865
972
|
)
|
|
866
973
|
)
|
|
867
974
|
elif verification_type not in VALID_VERIFICATION_TYPES:
|
|
975
|
+
hint = _suggest_value(verification_type, VALID_VERIFICATION_TYPES)
|
|
976
|
+
msg = f"Verify node '{node_id}' has invalid verification_type '{verification_type}'"
|
|
977
|
+
if hint:
|
|
978
|
+
msg += f"; {hint}"
|
|
868
979
|
result.diagnostics.append(
|
|
869
980
|
Diagnostic(
|
|
870
981
|
code="INVALID_VERIFICATION_TYPE",
|
|
871
|
-
message=
|
|
982
|
+
message=msg,
|
|
872
983
|
severity="error",
|
|
873
984
|
category="metadata",
|
|
874
985
|
location=node_id,
|
|
986
|
+
suggested_fix=f"Valid types: {', '.join(sorted(VALID_VERIFICATION_TYPES))}",
|
|
987
|
+
auto_fixable=True,
|
|
875
988
|
)
|
|
876
989
|
)
|
|
877
990
|
|
|
878
991
|
# Task nodes
|
|
879
992
|
if node_type == "task":
|
|
880
|
-
|
|
993
|
+
raw_task_category = metadata.get("task_category")
|
|
994
|
+
task_category = None
|
|
995
|
+
if isinstance(raw_task_category, str) and raw_task_category.strip():
|
|
996
|
+
task_category = raw_task_category.strip().lower()
|
|
997
|
+
|
|
998
|
+
# Check for common field name typo: 'category' instead of 'task_category'
|
|
999
|
+
if task_category is None and "category" in metadata and "task_category" not in metadata:
|
|
1000
|
+
result.diagnostics.append(
|
|
1001
|
+
Diagnostic(
|
|
1002
|
+
code="UNKNOWN_FIELD",
|
|
1003
|
+
message=f"Task node '{node_id}' has unknown field 'category'; did you mean 'task_category'?",
|
|
1004
|
+
severity="warning",
|
|
1005
|
+
category="metadata",
|
|
1006
|
+
location=node_id,
|
|
1007
|
+
suggested_fix="Rename 'category' to 'task_category'",
|
|
1008
|
+
auto_fixable=False,
|
|
1009
|
+
)
|
|
1010
|
+
)
|
|
881
1011
|
|
|
882
|
-
if
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1012
|
+
if task_category is not None and task_category not in VALID_TASK_CATEGORIES:
|
|
1013
|
+
hint = _suggest_value(task_category, VALID_TASK_CATEGORIES)
|
|
1014
|
+
msg = f"Task node '{node_id}' has invalid task_category '{task_category}'"
|
|
1015
|
+
if hint:
|
|
1016
|
+
msg += f"; {hint}"
|
|
886
1017
|
result.diagnostics.append(
|
|
887
1018
|
Diagnostic(
|
|
888
1019
|
code="INVALID_TASK_CATEGORY",
|
|
889
|
-
message=
|
|
1020
|
+
message=msg,
|
|
890
1021
|
severity="error",
|
|
891
1022
|
category="metadata",
|
|
892
1023
|
location=node_id,
|
|
893
|
-
suggested_fix=f"
|
|
894
|
-
auto_fixable=
|
|
1024
|
+
suggested_fix=f"Valid categories: {', '.join(sorted(VALID_TASK_CATEGORIES))}",
|
|
1025
|
+
auto_fixable=False, # Disabled: manual fix required
|
|
1026
|
+
)
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
if requires_rich_tasks and task_category is None:
|
|
1030
|
+
result.diagnostics.append(
|
|
1031
|
+
Diagnostic(
|
|
1032
|
+
code="MISSING_TASK_CATEGORY",
|
|
1033
|
+
message=f"Task node '{node_id}' missing metadata.task_category",
|
|
1034
|
+
severity="error",
|
|
1035
|
+
category="metadata",
|
|
1036
|
+
location=node_id,
|
|
1037
|
+
suggested_fix="Set metadata.task_category to a valid category",
|
|
1038
|
+
auto_fixable=False,
|
|
895
1039
|
)
|
|
896
1040
|
)
|
|
897
1041
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1042
|
+
if requires_rich_tasks and not _has_description(metadata):
|
|
1043
|
+
result.diagnostics.append(
|
|
1044
|
+
Diagnostic(
|
|
1045
|
+
code="MISSING_TASK_DESCRIPTION",
|
|
1046
|
+
message=f"Task node '{node_id}' missing metadata.description",
|
|
1047
|
+
severity="error",
|
|
1048
|
+
category="metadata",
|
|
1049
|
+
location=node_id,
|
|
1050
|
+
suggested_fix="Provide metadata.description (or details) for the task",
|
|
1051
|
+
auto_fixable=False,
|
|
1052
|
+
)
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
if requires_rich_tasks:
|
|
1056
|
+
acceptance_criteria = metadata.get("acceptance_criteria")
|
|
1057
|
+
if acceptance_criteria is None:
|
|
1058
|
+
result.diagnostics.append(
|
|
1059
|
+
Diagnostic(
|
|
1060
|
+
code="MISSING_ACCEPTANCE_CRITERIA",
|
|
1061
|
+
message=f"Task node '{node_id}' missing metadata.acceptance_criteria",
|
|
1062
|
+
severity="error",
|
|
1063
|
+
category="metadata",
|
|
1064
|
+
location=node_id,
|
|
1065
|
+
suggested_fix="Provide a non-empty acceptance_criteria list",
|
|
1066
|
+
auto_fixable=False,
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
elif not isinstance(acceptance_criteria, list):
|
|
1070
|
+
result.diagnostics.append(
|
|
1071
|
+
Diagnostic(
|
|
1072
|
+
code="INVALID_ACCEPTANCE_CRITERIA",
|
|
1073
|
+
message=(
|
|
1074
|
+
f"Task node '{node_id}' metadata.acceptance_criteria must be a list of strings"
|
|
1075
|
+
),
|
|
1076
|
+
severity="error",
|
|
1077
|
+
category="metadata",
|
|
1078
|
+
location=node_id,
|
|
1079
|
+
suggested_fix="Provide acceptance_criteria as an array of strings",
|
|
1080
|
+
auto_fixable=False,
|
|
1081
|
+
)
|
|
1082
|
+
)
|
|
1083
|
+
elif not acceptance_criteria:
|
|
1084
|
+
result.diagnostics.append(
|
|
1085
|
+
Diagnostic(
|
|
1086
|
+
code="MISSING_ACCEPTANCE_CRITERIA",
|
|
1087
|
+
message=f"Task node '{node_id}' must include at least one acceptance criterion",
|
|
1088
|
+
severity="error",
|
|
1089
|
+
category="metadata",
|
|
1090
|
+
location=node_id,
|
|
1091
|
+
suggested_fix="Add at least one acceptance criterion",
|
|
1092
|
+
auto_fixable=False,
|
|
1093
|
+
)
|
|
1094
|
+
)
|
|
1095
|
+
else:
|
|
1096
|
+
invalid_items = [
|
|
1097
|
+
idx
|
|
1098
|
+
for idx, item in enumerate(acceptance_criteria)
|
|
1099
|
+
if not _nonempty_string(item)
|
|
1100
|
+
]
|
|
1101
|
+
if invalid_items:
|
|
1102
|
+
result.diagnostics.append(
|
|
1103
|
+
Diagnostic(
|
|
1104
|
+
code="INVALID_ACCEPTANCE_CRITERIA",
|
|
1105
|
+
message=(
|
|
1106
|
+
f"Task node '{node_id}' has invalid acceptance_criteria entries"
|
|
1107
|
+
),
|
|
1108
|
+
severity="error",
|
|
1109
|
+
category="metadata",
|
|
1110
|
+
location=node_id,
|
|
1111
|
+
suggested_fix="Ensure acceptance_criteria contains non-empty strings",
|
|
1112
|
+
auto_fixable=False,
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
category_for_file_path = task_category
|
|
1117
|
+
if category_for_file_path is None:
|
|
1118
|
+
legacy_category = metadata.get("category")
|
|
1119
|
+
if isinstance(legacy_category, str) and legacy_category.strip():
|
|
1120
|
+
category_for_file_path = legacy_category.strip().lower()
|
|
1121
|
+
|
|
1122
|
+
# file_path required for implementation and refactoring.
|
|
1123
|
+
# Do not auto-generate placeholder paths; the authoring agent/user must
|
|
1124
|
+
# provide a real path in the target codebase.
|
|
1125
|
+
if category_for_file_path in ["implementation", "refactoring"]:
|
|
1126
|
+
file_path = metadata.get("file_path")
|
|
1127
|
+
if not _nonempty_string(file_path):
|
|
901
1128
|
result.diagnostics.append(
|
|
902
1129
|
Diagnostic(
|
|
903
1130
|
code="MISSING_FILE_PATH",
|
|
904
|
-
message=f"Task node '{node_id}' with category '{
|
|
1131
|
+
message=f"Task node '{node_id}' with category '{category_for_file_path}' missing metadata.file_path",
|
|
905
1132
|
severity="error",
|
|
906
1133
|
category="metadata",
|
|
907
1134
|
location=node_id,
|
|
908
|
-
suggested_fix=
|
|
909
|
-
|
|
1135
|
+
suggested_fix=(
|
|
1136
|
+
"Set metadata.file_path to the real repo-relative path of the primary file impacted"
|
|
1137
|
+
),
|
|
1138
|
+
auto_fixable=False,
|
|
910
1139
|
)
|
|
911
1140
|
)
|
|
912
1141
|
|
|
@@ -958,6 +1187,9 @@ def _build_fix_action(
|
|
|
958
1187
|
if code == "ORPHANED_NODES":
|
|
959
1188
|
return _build_orphan_fix(diag, hierarchy)
|
|
960
1189
|
|
|
1190
|
+
if code == "INVALID_ROOT_PARENT":
|
|
1191
|
+
return _build_root_parent_fix(diag, hierarchy)
|
|
1192
|
+
|
|
961
1193
|
if code == "MISSING_NODE_FIELD":
|
|
962
1194
|
return _build_missing_fields_fix(diag, hierarchy)
|
|
963
1195
|
|
|
@@ -987,11 +1219,12 @@ def _build_fix_action(
|
|
|
987
1219
|
if code == "MISSING_VERIFICATION_TYPE":
|
|
988
1220
|
return _build_verification_type_fix(diag, hierarchy)
|
|
989
1221
|
|
|
990
|
-
if code == "
|
|
991
|
-
return
|
|
1222
|
+
if code == "INVALID_VERIFICATION_TYPE":
|
|
1223
|
+
return _build_invalid_verification_type_fix(diag, hierarchy)
|
|
992
1224
|
|
|
993
|
-
|
|
994
|
-
|
|
1225
|
+
# INVALID_TASK_CATEGORY auto-fix disabled - manual correction required
|
|
1226
|
+
# if code == "INVALID_TASK_CATEGORY":
|
|
1227
|
+
# return _build_task_category_fix(diag, hierarchy)
|
|
995
1228
|
|
|
996
1229
|
return None
|
|
997
1230
|
|
|
@@ -1087,6 +1320,28 @@ def _build_orphan_fix(
|
|
|
1087
1320
|
)
|
|
1088
1321
|
|
|
1089
1322
|
|
|
1323
|
+
def _build_root_parent_fix(
|
|
1324
|
+
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1325
|
+
) -> Optional[FixAction]:
|
|
1326
|
+
"""Build fix for spec-root having non-null parent."""
|
|
1327
|
+
|
|
1328
|
+
def apply(data: Dict[str, Any]) -> None:
|
|
1329
|
+
hier = data.get("hierarchy", {})
|
|
1330
|
+
spec_root = hier.get("spec-root")
|
|
1331
|
+
if spec_root:
|
|
1332
|
+
spec_root["parent"] = None
|
|
1333
|
+
|
|
1334
|
+
return FixAction(
|
|
1335
|
+
id="hierarchy.fix_root_parent",
|
|
1336
|
+
description="Set spec-root parent to null",
|
|
1337
|
+
category="hierarchy",
|
|
1338
|
+
severity=diag.severity,
|
|
1339
|
+
auto_apply=True,
|
|
1340
|
+
preview="Set spec-root parent to null",
|
|
1341
|
+
apply=apply,
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
|
|
1090
1345
|
def _build_missing_fields_fix(
|
|
1091
1346
|
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1092
1347
|
) -> Optional[FixAction]:
|
|
@@ -1108,7 +1363,18 @@ def _build_missing_fields_fix(
|
|
|
1108
1363
|
if "status" not in node:
|
|
1109
1364
|
node["status"] = "pending"
|
|
1110
1365
|
if "parent" not in node:
|
|
1111
|
-
|
|
1366
|
+
# Find actual parent by checking which node lists this node as a child
|
|
1367
|
+
# This prevents regression where we set parent="spec-root" but the node
|
|
1368
|
+
# is actually in another node's children list (causing PARENT_CHILD_MISMATCH)
|
|
1369
|
+
actual_parent = "spec-root" # fallback if not found in any children list
|
|
1370
|
+
for other_id, other_node in hier.items():
|
|
1371
|
+
if not isinstance(other_node, dict):
|
|
1372
|
+
continue
|
|
1373
|
+
children = other_node.get("children", [])
|
|
1374
|
+
if isinstance(children, list) and node_id in children:
|
|
1375
|
+
actual_parent = other_id
|
|
1376
|
+
break
|
|
1377
|
+
node["parent"] = actual_parent
|
|
1112
1378
|
if "children" not in node:
|
|
1113
1379
|
node["children"] = []
|
|
1114
1380
|
if "total_tasks" not in node:
|
|
@@ -1262,9 +1528,9 @@ def _build_bidirectional_fix(
|
|
|
1262
1528
|
blocked_deps = blocked["dependencies"]
|
|
1263
1529
|
|
|
1264
1530
|
# Ensure all fields exist
|
|
1265
|
-
for
|
|
1266
|
-
blocker_deps.setdefault(
|
|
1267
|
-
blocked_deps.setdefault(
|
|
1531
|
+
for dep_key in ["blocks", "blocked_by", "depends"]:
|
|
1532
|
+
blocker_deps.setdefault(dep_key, [])
|
|
1533
|
+
blocked_deps.setdefault(dep_key, [])
|
|
1268
1534
|
|
|
1269
1535
|
# Sync relationship
|
|
1270
1536
|
if blocked_id not in blocker_deps["blocks"]:
|
|
@@ -1325,23 +1591,23 @@ def _build_verification_type_fix(
|
|
|
1325
1591
|
return
|
|
1326
1592
|
metadata = node.setdefault("metadata", {})
|
|
1327
1593
|
if "verification_type" not in metadata:
|
|
1328
|
-
metadata["verification_type"] = "
|
|
1594
|
+
metadata["verification_type"] = "run-tests"
|
|
1329
1595
|
|
|
1330
1596
|
return FixAction(
|
|
1331
1597
|
id=f"metadata.fix_verification_type:{node_id}",
|
|
1332
|
-
description=f"Set verification_type to '
|
|
1598
|
+
description=f"Set verification_type to 'run-tests' for {node_id}",
|
|
1333
1599
|
category="metadata",
|
|
1334
1600
|
severity=diag.severity,
|
|
1335
1601
|
auto_apply=True,
|
|
1336
|
-
preview=f"Set verification_type to '
|
|
1602
|
+
preview=f"Set verification_type to 'run-tests' for {node_id}",
|
|
1337
1603
|
apply=apply,
|
|
1338
1604
|
)
|
|
1339
1605
|
|
|
1340
1606
|
|
|
1341
|
-
def
|
|
1607
|
+
def _build_invalid_verification_type_fix(
|
|
1342
1608
|
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1343
1609
|
) -> Optional[FixAction]:
|
|
1344
|
-
"""Build fix for
|
|
1610
|
+
"""Build fix for invalid verification type by mapping to canonical value."""
|
|
1345
1611
|
node_id = diag.location
|
|
1346
1612
|
if not node_id:
|
|
1347
1613
|
return None
|
|
@@ -1351,21 +1617,31 @@ def _build_file_path_fix(
|
|
|
1351
1617
|
node = hier.get(node_id)
|
|
1352
1618
|
if not node:
|
|
1353
1619
|
return
|
|
1354
|
-
metadata = node.
|
|
1355
|
-
|
|
1356
|
-
|
|
1620
|
+
metadata = node.get("metadata", {})
|
|
1621
|
+
current_type = metadata.get("verification_type", "")
|
|
1622
|
+
|
|
1623
|
+
# Map legacy values to canonical
|
|
1624
|
+
mapped_type = VERIFICATION_TYPE_MAPPING.get(current_type)
|
|
1625
|
+
if mapped_type:
|
|
1626
|
+
metadata["verification_type"] = mapped_type
|
|
1627
|
+
elif current_type not in VALID_VERIFICATION_TYPES:
|
|
1628
|
+
metadata["verification_type"] = "manual" # safe fallback for unknown values
|
|
1357
1629
|
|
|
1358
1630
|
return FixAction(
|
|
1359
|
-
id=f"metadata.
|
|
1360
|
-
description=f"
|
|
1631
|
+
id=f"metadata.fix_invalid_verification_type:{node_id}",
|
|
1632
|
+
description=f"Map verification_type to canonical value for {node_id}",
|
|
1361
1633
|
category="metadata",
|
|
1362
1634
|
severity=diag.severity,
|
|
1363
1635
|
auto_apply=True,
|
|
1364
|
-
preview=f"
|
|
1636
|
+
preview=f"Map legacy verification_type to canonical value for {node_id}",
|
|
1365
1637
|
apply=apply,
|
|
1366
1638
|
)
|
|
1367
1639
|
|
|
1368
1640
|
|
|
1641
|
+
# NOTE: We intentionally do not auto-fix missing `metadata.file_path`.
|
|
1642
|
+
# It must be a real repo-relative path in the target workspace.
|
|
1643
|
+
|
|
1644
|
+
|
|
1369
1645
|
def _build_task_category_fix(
|
|
1370
1646
|
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1371
1647
|
) -> Optional[FixAction]:
|
|
@@ -36,63 +36,6 @@ def _check_deps():
|
|
|
36
36
|
return True
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def line_chart(
|
|
40
|
-
df: "pd.DataFrame",
|
|
41
|
-
x: str,
|
|
42
|
-
y: str,
|
|
43
|
-
title: Optional[str] = None,
|
|
44
|
-
color: Optional[str] = None,
|
|
45
|
-
height: int = 400,
|
|
46
|
-
) -> None:
|
|
47
|
-
"""Render an interactive line chart.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
df: DataFrame with data
|
|
51
|
-
x: Column name for x-axis
|
|
52
|
-
y: Column name for y-axis
|
|
53
|
-
title: Optional chart title
|
|
54
|
-
color: Optional column for color grouping
|
|
55
|
-
height: Chart height in pixels
|
|
56
|
-
"""
|
|
57
|
-
if not _check_deps():
|
|
58
|
-
return
|
|
59
|
-
|
|
60
|
-
if df is None or df.empty:
|
|
61
|
-
st.info("No data to display")
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
fig = px.line(
|
|
65
|
-
df,
|
|
66
|
-
x=x,
|
|
67
|
-
y=y,
|
|
68
|
-
title=title,
|
|
69
|
-
color=color,
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
# Configure layout for dark theme
|
|
73
|
-
fig.update_layout(
|
|
74
|
-
template="plotly_dark",
|
|
75
|
-
height=height,
|
|
76
|
-
margin=dict(l=20, r=20, t=40, b=20),
|
|
77
|
-
xaxis=dict(
|
|
78
|
-
rangeselector=dict(
|
|
79
|
-
buttons=list(
|
|
80
|
-
[
|
|
81
|
-
dict(count=1, label="1h", step="hour", stepmode="backward"),
|
|
82
|
-
dict(count=6, label="6h", step="hour", stepmode="backward"),
|
|
83
|
-
dict(count=24, label="24h", step="hour", stepmode="backward"),
|
|
84
|
-
dict(step="all", label="All"),
|
|
85
|
-
]
|
|
86
|
-
)
|
|
87
|
-
),
|
|
88
|
-
rangeslider=dict(visible=True),
|
|
89
|
-
type="date",
|
|
90
|
-
),
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
st.plotly_chart(fig, use_container_width=True)
|
|
94
|
-
|
|
95
|
-
|
|
96
39
|
def bar_chart(
|
|
97
40
|
df: "pd.DataFrame",
|
|
98
41
|
x: str,
|
|
@@ -126,6 +126,17 @@ def launch_dashboard(
|
|
|
126
126
|
"FOUNDRY_MCP_DASHBOARD_MODE": "1",
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
# Pass config file path to dashboard subprocess so it can find error/metrics storage
|
|
130
|
+
# If FOUNDRY_MCP_CONFIG_FILE is already set, it will be inherited from os.environ
|
|
131
|
+
# Otherwise, find and pass the config file path explicitly
|
|
132
|
+
if "FOUNDRY_MCP_CONFIG_FILE" not in env:
|
|
133
|
+
for config_name in ["foundry-mcp.toml", ".foundry-mcp.toml"]:
|
|
134
|
+
config_path = Path(config_name).resolve()
|
|
135
|
+
if config_path.exists():
|
|
136
|
+
env["FOUNDRY_MCP_CONFIG_FILE"] = str(config_path)
|
|
137
|
+
logger.debug("Passing config file to dashboard: %s", config_path)
|
|
138
|
+
break
|
|
139
|
+
|
|
129
140
|
try:
|
|
130
141
|
# Start subprocess
|
|
131
142
|
_dashboard_process = subprocess.Popen(
|