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.
Files changed (85) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/__init__.py +0 -13
  3. foundry_mcp/cli/commands/plan.py +10 -3
  4. foundry_mcp/cli/commands/review.py +19 -4
  5. foundry_mcp/cli/commands/session.py +1 -8
  6. foundry_mcp/cli/commands/specs.py +38 -208
  7. foundry_mcp/cli/context.py +39 -0
  8. foundry_mcp/cli/output.py +3 -3
  9. foundry_mcp/config.py +615 -11
  10. foundry_mcp/core/ai_consultation.py +146 -9
  11. foundry_mcp/core/batch_operations.py +1196 -0
  12. foundry_mcp/core/discovery.py +7 -7
  13. foundry_mcp/core/error_store.py +2 -2
  14. foundry_mcp/core/intake.py +933 -0
  15. foundry_mcp/core/llm_config.py +28 -2
  16. foundry_mcp/core/metrics_store.py +2 -2
  17. foundry_mcp/core/naming.py +25 -2
  18. foundry_mcp/core/progress.py +70 -0
  19. foundry_mcp/core/prometheus.py +0 -13
  20. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  21. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  22. foundry_mcp/core/prompts/plan_review.py +5 -1
  23. foundry_mcp/core/providers/__init__.py +12 -0
  24. foundry_mcp/core/providers/base.py +39 -0
  25. foundry_mcp/core/providers/claude.py +51 -48
  26. foundry_mcp/core/providers/codex.py +70 -60
  27. foundry_mcp/core/providers/cursor_agent.py +25 -47
  28. foundry_mcp/core/providers/detectors.py +34 -7
  29. foundry_mcp/core/providers/gemini.py +69 -58
  30. foundry_mcp/core/providers/opencode.py +101 -47
  31. foundry_mcp/core/providers/package-lock.json +4 -4
  32. foundry_mcp/core/providers/package.json +1 -1
  33. foundry_mcp/core/providers/validation.py +128 -0
  34. foundry_mcp/core/research/__init__.py +68 -0
  35. foundry_mcp/core/research/memory.py +528 -0
  36. foundry_mcp/core/research/models.py +1220 -0
  37. foundry_mcp/core/research/providers/__init__.py +40 -0
  38. foundry_mcp/core/research/providers/base.py +242 -0
  39. foundry_mcp/core/research/providers/google.py +507 -0
  40. foundry_mcp/core/research/providers/perplexity.py +442 -0
  41. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  42. foundry_mcp/core/research/providers/tavily.py +383 -0
  43. foundry_mcp/core/research/workflows/__init__.py +25 -0
  44. foundry_mcp/core/research/workflows/base.py +298 -0
  45. foundry_mcp/core/research/workflows/chat.py +271 -0
  46. foundry_mcp/core/research/workflows/consensus.py +539 -0
  47. foundry_mcp/core/research/workflows/deep_research.py +4020 -0
  48. foundry_mcp/core/research/workflows/ideate.py +682 -0
  49. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  50. foundry_mcp/core/responses.py +690 -0
  51. foundry_mcp/core/spec.py +2439 -236
  52. foundry_mcp/core/task.py +1205 -31
  53. foundry_mcp/core/testing.py +512 -123
  54. foundry_mcp/core/validation.py +319 -43
  55. foundry_mcp/dashboard/components/charts.py +0 -57
  56. foundry_mcp/dashboard/launcher.py +11 -0
  57. foundry_mcp/dashboard/views/metrics.py +25 -35
  58. foundry_mcp/dashboard/views/overview.py +1 -65
  59. foundry_mcp/resources/specs.py +25 -25
  60. foundry_mcp/schemas/intake-schema.json +89 -0
  61. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  62. foundry_mcp/server.py +0 -14
  63. foundry_mcp/tools/unified/__init__.py +39 -18
  64. foundry_mcp/tools/unified/authoring.py +2371 -248
  65. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  66. foundry_mcp/tools/unified/environment.py +434 -32
  67. foundry_mcp/tools/unified/error.py +18 -1
  68. foundry_mcp/tools/unified/lifecycle.py +8 -0
  69. foundry_mcp/tools/unified/plan.py +133 -2
  70. foundry_mcp/tools/unified/provider.py +0 -40
  71. foundry_mcp/tools/unified/research.py +1283 -0
  72. foundry_mcp/tools/unified/review.py +374 -17
  73. foundry_mcp/tools/unified/review_helpers.py +16 -1
  74. foundry_mcp/tools/unified/server.py +9 -24
  75. foundry_mcp/tools/unified/spec.py +367 -0
  76. foundry_mcp/tools/unified/task.py +1664 -30
  77. foundry_mcp/tools/unified/test.py +69 -8
  78. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/METADATA +8 -1
  79. foundry_mcp-0.8.10.dist-info/RECORD +153 -0
  80. foundry_mcp/cli/flags.py +0 -266
  81. foundry_mcp/core/feature_flags.py +0 -592
  82. foundry_mcp-0.3.3.dist-info/RECORD +0 -135
  83. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/WHEEL +0 -0
  84. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/entry_points.txt +0 -0
  85. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.8.10.dist-info}/licenses/LICENSE +0 -0
@@ -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 = {"test", "fidelity"}
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=f"Node '{node_id}' has invalid type '{node_type}'",
709
+ message=msg,
626
710
  severity="error",
627
711
  category="node",
628
712
  location=node_id,
629
- suggested_fix="Normalize node type to valid value",
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=f"Node '{node_id}' has invalid status '{status}'",
728
+ message=msg,
641
729
  severity="error",
642
730
  category="node",
643
731
  location=node_id,
644
- suggested_fix="Normalize status to pending/in_progress/completed/blocked",
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(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
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 'test' or 'fidelity'",
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=f"Verify node '{node_id}' verification_type must be 'test' or 'fidelity'",
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
- task_category = metadata.get("task_category", "implementation")
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
- "task_category" in metadata
884
- and task_category not in VALID_TASK_CATEGORIES
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=f"Task node '{node_id}' has invalid task_category '{task_category}'",
1020
+ message=msg,
890
1021
  severity="error",
891
1022
  category="metadata",
892
1023
  location=node_id,
893
- suggested_fix=f"Set task_category to one of: {', '.join(VALID_TASK_CATEGORIES)}",
894
- auto_fixable=True,
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
- # file_path required for implementation and refactoring
899
- if task_category in ["implementation", "refactoring"]:
900
- if "file_path" not in metadata:
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 '{task_category}' missing metadata.file_path",
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="Add metadata.file_path for implementation tasks",
909
- auto_fixable=True,
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 == "MISSING_FILE_PATH":
991
- return _build_file_path_fix(diag, hierarchy)
1222
+ if code == "INVALID_VERIFICATION_TYPE":
1223
+ return _build_invalid_verification_type_fix(diag, hierarchy)
992
1224
 
993
- if code == "INVALID_TASK_CATEGORY":
994
- return _build_task_category_fix(diag, hierarchy)
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
- node["parent"] = "spec-root"
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 field in ["blocks", "blocked_by", "depends"]:
1266
- blocker_deps.setdefault(field, [])
1267
- blocked_deps.setdefault(field, [])
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"] = "test"
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 'test' for {node_id}",
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 'test' for {node_id}",
1602
+ preview=f"Set verification_type to 'run-tests' for {node_id}",
1337
1603
  apply=apply,
1338
1604
  )
1339
1605
 
1340
1606
 
1341
- def _build_file_path_fix(
1607
+ def _build_invalid_verification_type_fix(
1342
1608
  diag: Diagnostic, hierarchy: Dict[str, Any]
1343
1609
  ) -> Optional[FixAction]:
1344
- """Build fix for missing file path."""
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.setdefault("metadata", {})
1355
- if "file_path" not in metadata:
1356
- metadata["file_path"] = f"{node_id}.py" # Default placeholder
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.add_file_path:{node_id}",
1360
- description=f"Add placeholder file_path for {node_id}",
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"Add placeholder file_path for {node_id}",
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(