foundry-mcp 0.3.3__py3-none-any.whl → 0.7.0__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/commands/plan.py +10 -3
- foundry_mcp/cli/commands/review.py +19 -4
- foundry_mcp/cli/commands/specs.py +38 -208
- foundry_mcp/cli/output.py +3 -3
- foundry_mcp/config.py +235 -5
- foundry_mcp/core/ai_consultation.py +146 -9
- foundry_mcp/core/discovery.py +6 -6
- foundry_mcp/core/error_store.py +2 -2
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/llm_config.py +20 -2
- foundry_mcp/core/metrics_store.py +2 -2
- foundry_mcp/core/progress.py +70 -0
- 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/claude.py +6 -47
- foundry_mcp/core/providers/codex.py +6 -57
- foundry_mcp/core/providers/cursor_agent.py +3 -44
- foundry_mcp/core/providers/gemini.py +6 -57
- foundry_mcp/core/providers/opencode.py +35 -5
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +425 -0
- foundry_mcp/core/research/models.py +437 -0
- foundry_mcp/core/research/workflows/__init__.py +22 -0
- foundry_mcp/core/research/workflows/base.py +204 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +396 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/responses.py +450 -0
- foundry_mcp/core/spec.py +2438 -236
- foundry_mcp/core/task.py +1064 -19
- foundry_mcp/core/testing.py +512 -123
- foundry_mcp/core/validation.py +313 -42
- 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 +38 -0
- foundry_mcp/tools/unified/__init__.py +4 -2
- foundry_mcp/tools/unified/authoring.py +2423 -267
- foundry_mcp/tools/unified/documentation_helpers.py +69 -6
- foundry_mcp/tools/unified/environment.py +235 -6
- foundry_mcp/tools/unified/error.py +18 -1
- foundry_mcp/tools/unified/lifecycle.py +8 -0
- foundry_mcp/tools/unified/plan.py +113 -1
- foundry_mcp/tools/unified/research.py +658 -0
- foundry_mcp/tools/unified/review.py +370 -16
- foundry_mcp/tools/unified/spec.py +367 -0
- foundry_mcp/tools/unified/task.py +1163 -48
- foundry_mcp/tools/unified/test.py +69 -8
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/METADATA +7 -1
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/RECORD +60 -48
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
- {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.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
|
|
@@ -118,12 +119,68 @@ 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
|
+
# Common field name typos/alternatives
|
|
131
|
+
FIELD_NAME_SUGGESTIONS = {
|
|
132
|
+
"category": "task_category",
|
|
133
|
+
"type": "node type or verification_type",
|
|
134
|
+
"desc": "description",
|
|
135
|
+
"details": "description",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _suggest_value(value: str, valid_values: set, n: int = 1) -> Optional[str]:
|
|
140
|
+
"""
|
|
141
|
+
Suggest a close match for an invalid value.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
value: The invalid value provided
|
|
145
|
+
valid_values: Set of valid values to match against
|
|
146
|
+
n: Number of suggestions to return (default 1)
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Suggestion string like "did you mean 'X'?" or None if no close match
|
|
150
|
+
"""
|
|
151
|
+
if not value:
|
|
152
|
+
return None
|
|
153
|
+
matches = get_close_matches(value.lower(), [v.lower() for v in valid_values], n=n, cutoff=0.6)
|
|
154
|
+
if matches:
|
|
155
|
+
# Find the original-case version of the match
|
|
156
|
+
for v in valid_values:
|
|
157
|
+
if v.lower() == matches[0]:
|
|
158
|
+
return f"did you mean '{v}'?"
|
|
159
|
+
return f"did you mean '{matches[0]}'?"
|
|
160
|
+
return None
|
|
122
161
|
|
|
123
162
|
|
|
124
163
|
# Validation functions
|
|
125
164
|
|
|
126
165
|
|
|
166
|
+
def _requires_rich_task_fields(spec_data: Dict[str, Any]) -> bool:
|
|
167
|
+
"""Check if spec requires rich task fields based on explicit complexity metadata."""
|
|
168
|
+
metadata = spec_data.get("metadata", {})
|
|
169
|
+
if not isinstance(metadata, dict):
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
# Only check explicit complexity metadata (template no longer indicates complexity)
|
|
173
|
+
complexity = metadata.get("complexity")
|
|
174
|
+
if isinstance(complexity, str) and complexity.strip().lower() in {
|
|
175
|
+
"medium",
|
|
176
|
+
"complex",
|
|
177
|
+
"high",
|
|
178
|
+
}:
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
|
|
127
184
|
def validate_spec_input(
|
|
128
185
|
raw_input: str | bytes,
|
|
129
186
|
*,
|
|
@@ -247,7 +304,7 @@ def validate_spec(spec_data: Dict[str, Any]) -> ValidationResult:
|
|
|
247
304
|
_validate_nodes(hierarchy, result)
|
|
248
305
|
_validate_task_counts(hierarchy, result)
|
|
249
306
|
_validate_dependencies(hierarchy, result)
|
|
250
|
-
_validate_metadata(hierarchy, result)
|
|
307
|
+
_validate_metadata(spec_data, hierarchy, result)
|
|
251
308
|
|
|
252
309
|
# Count diagnostics by severity
|
|
253
310
|
for diag in result.diagnostics:
|
|
@@ -413,6 +470,22 @@ def _validate_structure(spec_data: Dict[str, Any], result: ValidationResult) ->
|
|
|
413
470
|
)
|
|
414
471
|
)
|
|
415
472
|
|
|
473
|
+
if _requires_rich_task_fields(spec_data):
|
|
474
|
+
metadata = spec_data.get("metadata", {})
|
|
475
|
+
mission = metadata.get("mission") if isinstance(metadata, dict) else None
|
|
476
|
+
if not isinstance(mission, str) or not mission.strip():
|
|
477
|
+
result.diagnostics.append(
|
|
478
|
+
Diagnostic(
|
|
479
|
+
code="MISSING_MISSION",
|
|
480
|
+
message="Spec metadata.mission is required when complexity is medium/complex/high",
|
|
481
|
+
severity="error",
|
|
482
|
+
category="metadata",
|
|
483
|
+
location="metadata.mission",
|
|
484
|
+
suggested_fix="Set metadata.mission to a concise goal statement",
|
|
485
|
+
auto_fixable=False,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
|
|
416
489
|
# Check hierarchy is dict
|
|
417
490
|
hierarchy = spec_data.get("hierarchy")
|
|
418
491
|
if hierarchy is not None and not isinstance(hierarchy, dict):
|
|
@@ -458,6 +531,8 @@ def _validate_hierarchy(hierarchy: Dict[str, Any], result: ValidationResult) ->
|
|
|
458
531
|
severity="error",
|
|
459
532
|
category="hierarchy",
|
|
460
533
|
location="spec-root",
|
|
534
|
+
suggested_fix="Set spec-root parent to null",
|
|
535
|
+
auto_fixable=True,
|
|
461
536
|
)
|
|
462
537
|
)
|
|
463
538
|
|
|
@@ -619,14 +694,18 @@ def _validate_nodes(hierarchy: Dict[str, Any], result: ValidationResult) -> None
|
|
|
619
694
|
# Validate type
|
|
620
695
|
node_type = node.get("type")
|
|
621
696
|
if node_type and node_type not in VALID_NODE_TYPES:
|
|
697
|
+
hint = _suggest_value(node_type, VALID_NODE_TYPES)
|
|
698
|
+
msg = f"Node '{node_id}' has invalid type '{node_type}'"
|
|
699
|
+
if hint:
|
|
700
|
+
msg += f"; {hint}"
|
|
622
701
|
result.diagnostics.append(
|
|
623
702
|
Diagnostic(
|
|
624
703
|
code="INVALID_NODE_TYPE",
|
|
625
|
-
message=
|
|
704
|
+
message=msg,
|
|
626
705
|
severity="error",
|
|
627
706
|
category="node",
|
|
628
707
|
location=node_id,
|
|
629
|
-
suggested_fix="
|
|
708
|
+
suggested_fix=f"Valid types: {', '.join(sorted(VALID_NODE_TYPES))}",
|
|
630
709
|
auto_fixable=True,
|
|
631
710
|
)
|
|
632
711
|
)
|
|
@@ -634,14 +713,18 @@ def _validate_nodes(hierarchy: Dict[str, Any], result: ValidationResult) -> None
|
|
|
634
713
|
# Validate status
|
|
635
714
|
status = node.get("status")
|
|
636
715
|
if status and status not in VALID_STATUSES:
|
|
716
|
+
hint = _suggest_value(status, VALID_STATUSES)
|
|
717
|
+
msg = f"Node '{node_id}' has invalid status '{status}'"
|
|
718
|
+
if hint:
|
|
719
|
+
msg += f"; {hint}"
|
|
637
720
|
result.diagnostics.append(
|
|
638
721
|
Diagnostic(
|
|
639
722
|
code="INVALID_STATUS",
|
|
640
|
-
message=
|
|
723
|
+
message=msg,
|
|
641
724
|
severity="error",
|
|
642
725
|
category="node",
|
|
643
726
|
location=node_id,
|
|
644
|
-
suggested_fix="
|
|
727
|
+
suggested_fix=f"Valid statuses: {', '.join(sorted(VALID_STATUSES))}",
|
|
645
728
|
auto_fixable=True,
|
|
646
729
|
)
|
|
647
730
|
)
|
|
@@ -830,8 +913,27 @@ def _validate_dependencies(hierarchy: Dict[str, Any], result: ValidationResult)
|
|
|
830
913
|
)
|
|
831
914
|
|
|
832
915
|
|
|
833
|
-
def _validate_metadata(
|
|
916
|
+
def _validate_metadata(
|
|
917
|
+
spec_data: Dict[str, Any],
|
|
918
|
+
hierarchy: Dict[str, Any],
|
|
919
|
+
result: ValidationResult,
|
|
920
|
+
) -> None:
|
|
834
921
|
"""Validate type-specific metadata requirements."""
|
|
922
|
+
requires_rich_tasks = _requires_rich_task_fields(spec_data)
|
|
923
|
+
|
|
924
|
+
def _nonempty_string(value: Any) -> bool:
|
|
925
|
+
return isinstance(value, str) and bool(value.strip())
|
|
926
|
+
|
|
927
|
+
def _has_description(metadata: Dict[str, Any]) -> bool:
|
|
928
|
+
if _nonempty_string(metadata.get("description")):
|
|
929
|
+
return True
|
|
930
|
+
details = metadata.get("details")
|
|
931
|
+
if _nonempty_string(details):
|
|
932
|
+
return True
|
|
933
|
+
if isinstance(details, list):
|
|
934
|
+
return any(_nonempty_string(item) for item in details)
|
|
935
|
+
return False
|
|
936
|
+
|
|
835
937
|
for node_id, node in _iter_valid_nodes(hierarchy, result, report_invalid=False):
|
|
836
938
|
node_type = node.get("type")
|
|
837
939
|
metadata = node.get("metadata", {})
|
|
@@ -860,53 +962,175 @@ def _validate_metadata(hierarchy: Dict[str, Any], result: ValidationResult) -> N
|
|
|
860
962
|
severity="error",
|
|
861
963
|
category="metadata",
|
|
862
964
|
location=node_id,
|
|
863
|
-
suggested_fix="Set verification_type to '
|
|
965
|
+
suggested_fix="Set verification_type to 'run-tests', 'fidelity', or 'manual'",
|
|
864
966
|
auto_fixable=True,
|
|
865
967
|
)
|
|
866
968
|
)
|
|
867
969
|
elif verification_type not in VALID_VERIFICATION_TYPES:
|
|
970
|
+
hint = _suggest_value(verification_type, VALID_VERIFICATION_TYPES)
|
|
971
|
+
msg = f"Verify node '{node_id}' has invalid verification_type '{verification_type}'"
|
|
972
|
+
if hint:
|
|
973
|
+
msg += f"; {hint}"
|
|
868
974
|
result.diagnostics.append(
|
|
869
975
|
Diagnostic(
|
|
870
976
|
code="INVALID_VERIFICATION_TYPE",
|
|
871
|
-
message=
|
|
977
|
+
message=msg,
|
|
872
978
|
severity="error",
|
|
873
979
|
category="metadata",
|
|
874
980
|
location=node_id,
|
|
981
|
+
suggested_fix=f"Valid types: {', '.join(sorted(VALID_VERIFICATION_TYPES))}",
|
|
982
|
+
auto_fixable=True,
|
|
875
983
|
)
|
|
876
984
|
)
|
|
877
985
|
|
|
878
986
|
# Task nodes
|
|
879
987
|
if node_type == "task":
|
|
880
|
-
|
|
988
|
+
raw_task_category = metadata.get("task_category")
|
|
989
|
+
task_category = None
|
|
990
|
+
if isinstance(raw_task_category, str) and raw_task_category.strip():
|
|
991
|
+
task_category = raw_task_category.strip().lower()
|
|
992
|
+
|
|
993
|
+
# Check for common field name typo: 'category' instead of 'task_category'
|
|
994
|
+
if task_category is None and "category" in metadata and "task_category" not in metadata:
|
|
995
|
+
result.diagnostics.append(
|
|
996
|
+
Diagnostic(
|
|
997
|
+
code="UNKNOWN_FIELD",
|
|
998
|
+
message=f"Task node '{node_id}' has unknown field 'category'; did you mean 'task_category'?",
|
|
999
|
+
severity="warning",
|
|
1000
|
+
category="metadata",
|
|
1001
|
+
location=node_id,
|
|
1002
|
+
suggested_fix="Rename 'category' to 'task_category'",
|
|
1003
|
+
auto_fixable=False,
|
|
1004
|
+
)
|
|
1005
|
+
)
|
|
881
1006
|
|
|
882
|
-
if
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1007
|
+
if task_category is not None and task_category not in VALID_TASK_CATEGORIES:
|
|
1008
|
+
hint = _suggest_value(task_category, VALID_TASK_CATEGORIES)
|
|
1009
|
+
msg = f"Task node '{node_id}' has invalid task_category '{task_category}'"
|
|
1010
|
+
if hint:
|
|
1011
|
+
msg += f"; {hint}"
|
|
886
1012
|
result.diagnostics.append(
|
|
887
1013
|
Diagnostic(
|
|
888
1014
|
code="INVALID_TASK_CATEGORY",
|
|
889
|
-
message=
|
|
1015
|
+
message=msg,
|
|
890
1016
|
severity="error",
|
|
891
1017
|
category="metadata",
|
|
892
1018
|
location=node_id,
|
|
893
|
-
suggested_fix=f"
|
|
894
|
-
auto_fixable=
|
|
1019
|
+
suggested_fix=f"Valid categories: {', '.join(sorted(VALID_TASK_CATEGORIES))}",
|
|
1020
|
+
auto_fixable=False, # Disabled: manual fix required
|
|
1021
|
+
)
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
if requires_rich_tasks and task_category is None:
|
|
1025
|
+
result.diagnostics.append(
|
|
1026
|
+
Diagnostic(
|
|
1027
|
+
code="MISSING_TASK_CATEGORY",
|
|
1028
|
+
message=f"Task node '{node_id}' missing metadata.task_category",
|
|
1029
|
+
severity="error",
|
|
1030
|
+
category="metadata",
|
|
1031
|
+
location=node_id,
|
|
1032
|
+
suggested_fix="Set metadata.task_category to a valid category",
|
|
1033
|
+
auto_fixable=False,
|
|
895
1034
|
)
|
|
896
1035
|
)
|
|
897
1036
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1037
|
+
if requires_rich_tasks and not _has_description(metadata):
|
|
1038
|
+
result.diagnostics.append(
|
|
1039
|
+
Diagnostic(
|
|
1040
|
+
code="MISSING_TASK_DESCRIPTION",
|
|
1041
|
+
message=f"Task node '{node_id}' missing metadata.description",
|
|
1042
|
+
severity="error",
|
|
1043
|
+
category="metadata",
|
|
1044
|
+
location=node_id,
|
|
1045
|
+
suggested_fix="Provide metadata.description (or details) for the task",
|
|
1046
|
+
auto_fixable=False,
|
|
1047
|
+
)
|
|
1048
|
+
)
|
|
1049
|
+
|
|
1050
|
+
if requires_rich_tasks:
|
|
1051
|
+
acceptance_criteria = metadata.get("acceptance_criteria")
|
|
1052
|
+
if acceptance_criteria is None:
|
|
1053
|
+
result.diagnostics.append(
|
|
1054
|
+
Diagnostic(
|
|
1055
|
+
code="MISSING_ACCEPTANCE_CRITERIA",
|
|
1056
|
+
message=f"Task node '{node_id}' missing metadata.acceptance_criteria",
|
|
1057
|
+
severity="error",
|
|
1058
|
+
category="metadata",
|
|
1059
|
+
location=node_id,
|
|
1060
|
+
suggested_fix="Provide a non-empty acceptance_criteria list",
|
|
1061
|
+
auto_fixable=False,
|
|
1062
|
+
)
|
|
1063
|
+
)
|
|
1064
|
+
elif not isinstance(acceptance_criteria, list):
|
|
1065
|
+
result.diagnostics.append(
|
|
1066
|
+
Diagnostic(
|
|
1067
|
+
code="INVALID_ACCEPTANCE_CRITERIA",
|
|
1068
|
+
message=(
|
|
1069
|
+
f"Task node '{node_id}' metadata.acceptance_criteria must be a list of strings"
|
|
1070
|
+
),
|
|
1071
|
+
severity="error",
|
|
1072
|
+
category="metadata",
|
|
1073
|
+
location=node_id,
|
|
1074
|
+
suggested_fix="Provide acceptance_criteria as an array of strings",
|
|
1075
|
+
auto_fixable=False,
|
|
1076
|
+
)
|
|
1077
|
+
)
|
|
1078
|
+
elif not acceptance_criteria:
|
|
1079
|
+
result.diagnostics.append(
|
|
1080
|
+
Diagnostic(
|
|
1081
|
+
code="MISSING_ACCEPTANCE_CRITERIA",
|
|
1082
|
+
message=f"Task node '{node_id}' must include at least one acceptance criterion",
|
|
1083
|
+
severity="error",
|
|
1084
|
+
category="metadata",
|
|
1085
|
+
location=node_id,
|
|
1086
|
+
suggested_fix="Add at least one acceptance criterion",
|
|
1087
|
+
auto_fixable=False,
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
else:
|
|
1091
|
+
invalid_items = [
|
|
1092
|
+
idx
|
|
1093
|
+
for idx, item in enumerate(acceptance_criteria)
|
|
1094
|
+
if not _nonempty_string(item)
|
|
1095
|
+
]
|
|
1096
|
+
if invalid_items:
|
|
1097
|
+
result.diagnostics.append(
|
|
1098
|
+
Diagnostic(
|
|
1099
|
+
code="INVALID_ACCEPTANCE_CRITERIA",
|
|
1100
|
+
message=(
|
|
1101
|
+
f"Task node '{node_id}' has invalid acceptance_criteria entries"
|
|
1102
|
+
),
|
|
1103
|
+
severity="error",
|
|
1104
|
+
category="metadata",
|
|
1105
|
+
location=node_id,
|
|
1106
|
+
suggested_fix="Ensure acceptance_criteria contains non-empty strings",
|
|
1107
|
+
auto_fixable=False,
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
category_for_file_path = task_category
|
|
1112
|
+
if category_for_file_path is None:
|
|
1113
|
+
legacy_category = metadata.get("category")
|
|
1114
|
+
if isinstance(legacy_category, str) and legacy_category.strip():
|
|
1115
|
+
category_for_file_path = legacy_category.strip().lower()
|
|
1116
|
+
|
|
1117
|
+
# file_path required for implementation and refactoring.
|
|
1118
|
+
# Do not auto-generate placeholder paths; the authoring agent/user must
|
|
1119
|
+
# provide a real path in the target codebase.
|
|
1120
|
+
if category_for_file_path in ["implementation", "refactoring"]:
|
|
1121
|
+
file_path = metadata.get("file_path")
|
|
1122
|
+
if not _nonempty_string(file_path):
|
|
901
1123
|
result.diagnostics.append(
|
|
902
1124
|
Diagnostic(
|
|
903
1125
|
code="MISSING_FILE_PATH",
|
|
904
|
-
message=f"Task node '{node_id}' with category '{
|
|
1126
|
+
message=f"Task node '{node_id}' with category '{category_for_file_path}' missing metadata.file_path",
|
|
905
1127
|
severity="error",
|
|
906
1128
|
category="metadata",
|
|
907
1129
|
location=node_id,
|
|
908
|
-
suggested_fix=
|
|
909
|
-
|
|
1130
|
+
suggested_fix=(
|
|
1131
|
+
"Set metadata.file_path to the real repo-relative path of the primary file impacted"
|
|
1132
|
+
),
|
|
1133
|
+
auto_fixable=False,
|
|
910
1134
|
)
|
|
911
1135
|
)
|
|
912
1136
|
|
|
@@ -958,6 +1182,9 @@ def _build_fix_action(
|
|
|
958
1182
|
if code == "ORPHANED_NODES":
|
|
959
1183
|
return _build_orphan_fix(diag, hierarchy)
|
|
960
1184
|
|
|
1185
|
+
if code == "INVALID_ROOT_PARENT":
|
|
1186
|
+
return _build_root_parent_fix(diag, hierarchy)
|
|
1187
|
+
|
|
961
1188
|
if code == "MISSING_NODE_FIELD":
|
|
962
1189
|
return _build_missing_fields_fix(diag, hierarchy)
|
|
963
1190
|
|
|
@@ -987,11 +1214,12 @@ def _build_fix_action(
|
|
|
987
1214
|
if code == "MISSING_VERIFICATION_TYPE":
|
|
988
1215
|
return _build_verification_type_fix(diag, hierarchy)
|
|
989
1216
|
|
|
990
|
-
if code == "
|
|
991
|
-
return
|
|
1217
|
+
if code == "INVALID_VERIFICATION_TYPE":
|
|
1218
|
+
return _build_invalid_verification_type_fix(diag, hierarchy)
|
|
992
1219
|
|
|
993
|
-
|
|
994
|
-
|
|
1220
|
+
# INVALID_TASK_CATEGORY auto-fix disabled - manual correction required
|
|
1221
|
+
# if code == "INVALID_TASK_CATEGORY":
|
|
1222
|
+
# return _build_task_category_fix(diag, hierarchy)
|
|
995
1223
|
|
|
996
1224
|
return None
|
|
997
1225
|
|
|
@@ -1087,6 +1315,28 @@ def _build_orphan_fix(
|
|
|
1087
1315
|
)
|
|
1088
1316
|
|
|
1089
1317
|
|
|
1318
|
+
def _build_root_parent_fix(
|
|
1319
|
+
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1320
|
+
) -> Optional[FixAction]:
|
|
1321
|
+
"""Build fix for spec-root having non-null parent."""
|
|
1322
|
+
|
|
1323
|
+
def apply(data: Dict[str, Any]) -> None:
|
|
1324
|
+
hier = data.get("hierarchy", {})
|
|
1325
|
+
spec_root = hier.get("spec-root")
|
|
1326
|
+
if spec_root:
|
|
1327
|
+
spec_root["parent"] = None
|
|
1328
|
+
|
|
1329
|
+
return FixAction(
|
|
1330
|
+
id="hierarchy.fix_root_parent",
|
|
1331
|
+
description="Set spec-root parent to null",
|
|
1332
|
+
category="hierarchy",
|
|
1333
|
+
severity=diag.severity,
|
|
1334
|
+
auto_apply=True,
|
|
1335
|
+
preview="Set spec-root parent to null",
|
|
1336
|
+
apply=apply,
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
|
|
1090
1340
|
def _build_missing_fields_fix(
|
|
1091
1341
|
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1092
1342
|
) -> Optional[FixAction]:
|
|
@@ -1108,7 +1358,18 @@ def _build_missing_fields_fix(
|
|
|
1108
1358
|
if "status" not in node:
|
|
1109
1359
|
node["status"] = "pending"
|
|
1110
1360
|
if "parent" not in node:
|
|
1111
|
-
|
|
1361
|
+
# Find actual parent by checking which node lists this node as a child
|
|
1362
|
+
# This prevents regression where we set parent="spec-root" but the node
|
|
1363
|
+
# is actually in another node's children list (causing PARENT_CHILD_MISMATCH)
|
|
1364
|
+
actual_parent = "spec-root" # fallback if not found in any children list
|
|
1365
|
+
for other_id, other_node in hier.items():
|
|
1366
|
+
if not isinstance(other_node, dict):
|
|
1367
|
+
continue
|
|
1368
|
+
children = other_node.get("children", [])
|
|
1369
|
+
if isinstance(children, list) and node_id in children:
|
|
1370
|
+
actual_parent = other_id
|
|
1371
|
+
break
|
|
1372
|
+
node["parent"] = actual_parent
|
|
1112
1373
|
if "children" not in node:
|
|
1113
1374
|
node["children"] = []
|
|
1114
1375
|
if "total_tasks" not in node:
|
|
@@ -1262,9 +1523,9 @@ def _build_bidirectional_fix(
|
|
|
1262
1523
|
blocked_deps = blocked["dependencies"]
|
|
1263
1524
|
|
|
1264
1525
|
# Ensure all fields exist
|
|
1265
|
-
for
|
|
1266
|
-
blocker_deps.setdefault(
|
|
1267
|
-
blocked_deps.setdefault(
|
|
1526
|
+
for dep_key in ["blocks", "blocked_by", "depends"]:
|
|
1527
|
+
blocker_deps.setdefault(dep_key, [])
|
|
1528
|
+
blocked_deps.setdefault(dep_key, [])
|
|
1268
1529
|
|
|
1269
1530
|
# Sync relationship
|
|
1270
1531
|
if blocked_id not in blocker_deps["blocks"]:
|
|
@@ -1325,23 +1586,23 @@ def _build_verification_type_fix(
|
|
|
1325
1586
|
return
|
|
1326
1587
|
metadata = node.setdefault("metadata", {})
|
|
1327
1588
|
if "verification_type" not in metadata:
|
|
1328
|
-
metadata["verification_type"] = "
|
|
1589
|
+
metadata["verification_type"] = "run-tests"
|
|
1329
1590
|
|
|
1330
1591
|
return FixAction(
|
|
1331
1592
|
id=f"metadata.fix_verification_type:{node_id}",
|
|
1332
|
-
description=f"Set verification_type to '
|
|
1593
|
+
description=f"Set verification_type to 'run-tests' for {node_id}",
|
|
1333
1594
|
category="metadata",
|
|
1334
1595
|
severity=diag.severity,
|
|
1335
1596
|
auto_apply=True,
|
|
1336
|
-
preview=f"Set verification_type to '
|
|
1597
|
+
preview=f"Set verification_type to 'run-tests' for {node_id}",
|
|
1337
1598
|
apply=apply,
|
|
1338
1599
|
)
|
|
1339
1600
|
|
|
1340
1601
|
|
|
1341
|
-
def
|
|
1602
|
+
def _build_invalid_verification_type_fix(
|
|
1342
1603
|
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1343
1604
|
) -> Optional[FixAction]:
|
|
1344
|
-
"""Build fix for
|
|
1605
|
+
"""Build fix for invalid verification type by mapping to canonical value."""
|
|
1345
1606
|
node_id = diag.location
|
|
1346
1607
|
if not node_id:
|
|
1347
1608
|
return None
|
|
@@ -1351,21 +1612,31 @@ def _build_file_path_fix(
|
|
|
1351
1612
|
node = hier.get(node_id)
|
|
1352
1613
|
if not node:
|
|
1353
1614
|
return
|
|
1354
|
-
metadata = node.
|
|
1355
|
-
|
|
1356
|
-
|
|
1615
|
+
metadata = node.get("metadata", {})
|
|
1616
|
+
current_type = metadata.get("verification_type", "")
|
|
1617
|
+
|
|
1618
|
+
# Map legacy values to canonical
|
|
1619
|
+
mapped_type = VERIFICATION_TYPE_MAPPING.get(current_type)
|
|
1620
|
+
if mapped_type:
|
|
1621
|
+
metadata["verification_type"] = mapped_type
|
|
1622
|
+
elif current_type not in VALID_VERIFICATION_TYPES:
|
|
1623
|
+
metadata["verification_type"] = "manual" # safe fallback for unknown values
|
|
1357
1624
|
|
|
1358
1625
|
return FixAction(
|
|
1359
|
-
id=f"metadata.
|
|
1360
|
-
description=f"
|
|
1626
|
+
id=f"metadata.fix_invalid_verification_type:{node_id}",
|
|
1627
|
+
description=f"Map verification_type to canonical value for {node_id}",
|
|
1361
1628
|
category="metadata",
|
|
1362
1629
|
severity=diag.severity,
|
|
1363
1630
|
auto_apply=True,
|
|
1364
|
-
preview=f"
|
|
1631
|
+
preview=f"Map legacy verification_type to canonical value for {node_id}",
|
|
1365
1632
|
apply=apply,
|
|
1366
1633
|
)
|
|
1367
1634
|
|
|
1368
1635
|
|
|
1636
|
+
# NOTE: We intentionally do not auto-fix missing `metadata.file_path`.
|
|
1637
|
+
# It must be a real repo-relative path in the target workspace.
|
|
1638
|
+
|
|
1639
|
+
|
|
1369
1640
|
def _build_task_category_fix(
|
|
1370
1641
|
diag: Diagnostic, hierarchy: Dict[str, Any]
|
|
1371
1642
|
) -> 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(
|