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.
Files changed (60) hide show
  1. foundry_mcp/__init__.py +7 -1
  2. foundry_mcp/cli/commands/plan.py +10 -3
  3. foundry_mcp/cli/commands/review.py +19 -4
  4. foundry_mcp/cli/commands/specs.py +38 -208
  5. foundry_mcp/cli/output.py +3 -3
  6. foundry_mcp/config.py +235 -5
  7. foundry_mcp/core/ai_consultation.py +146 -9
  8. foundry_mcp/core/discovery.py +6 -6
  9. foundry_mcp/core/error_store.py +2 -2
  10. foundry_mcp/core/intake.py +933 -0
  11. foundry_mcp/core/llm_config.py +20 -2
  12. foundry_mcp/core/metrics_store.py +2 -2
  13. foundry_mcp/core/progress.py +70 -0
  14. foundry_mcp/core/prompts/fidelity_review.py +149 -4
  15. foundry_mcp/core/prompts/markdown_plan_review.py +5 -1
  16. foundry_mcp/core/prompts/plan_review.py +5 -1
  17. foundry_mcp/core/providers/claude.py +6 -47
  18. foundry_mcp/core/providers/codex.py +6 -57
  19. foundry_mcp/core/providers/cursor_agent.py +3 -44
  20. foundry_mcp/core/providers/gemini.py +6 -57
  21. foundry_mcp/core/providers/opencode.py +35 -5
  22. foundry_mcp/core/research/__init__.py +68 -0
  23. foundry_mcp/core/research/memory.py +425 -0
  24. foundry_mcp/core/research/models.py +437 -0
  25. foundry_mcp/core/research/workflows/__init__.py +22 -0
  26. foundry_mcp/core/research/workflows/base.py +204 -0
  27. foundry_mcp/core/research/workflows/chat.py +271 -0
  28. foundry_mcp/core/research/workflows/consensus.py +396 -0
  29. foundry_mcp/core/research/workflows/ideate.py +682 -0
  30. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  31. foundry_mcp/core/responses.py +450 -0
  32. foundry_mcp/core/spec.py +2438 -236
  33. foundry_mcp/core/task.py +1064 -19
  34. foundry_mcp/core/testing.py +512 -123
  35. foundry_mcp/core/validation.py +313 -42
  36. foundry_mcp/dashboard/components/charts.py +0 -57
  37. foundry_mcp/dashboard/launcher.py +11 -0
  38. foundry_mcp/dashboard/views/metrics.py +25 -35
  39. foundry_mcp/dashboard/views/overview.py +1 -65
  40. foundry_mcp/resources/specs.py +25 -25
  41. foundry_mcp/schemas/intake-schema.json +89 -0
  42. foundry_mcp/schemas/sdd-spec-schema.json +33 -5
  43. foundry_mcp/server.py +38 -0
  44. foundry_mcp/tools/unified/__init__.py +4 -2
  45. foundry_mcp/tools/unified/authoring.py +2423 -267
  46. foundry_mcp/tools/unified/documentation_helpers.py +69 -6
  47. foundry_mcp/tools/unified/environment.py +235 -6
  48. foundry_mcp/tools/unified/error.py +18 -1
  49. foundry_mcp/tools/unified/lifecycle.py +8 -0
  50. foundry_mcp/tools/unified/plan.py +113 -1
  51. foundry_mcp/tools/unified/research.py +658 -0
  52. foundry_mcp/tools/unified/review.py +370 -16
  53. foundry_mcp/tools/unified/spec.py +367 -0
  54. foundry_mcp/tools/unified/task.py +1163 -48
  55. foundry_mcp/tools/unified/test.py +69 -8
  56. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/METADATA +7 -1
  57. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/RECORD +60 -48
  58. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/WHEEL +0 -0
  59. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
  60. {foundry_mcp-0.3.3.dist-info → foundry_mcp-0.7.0.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
@@ -118,12 +119,68 @@ 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
+ # 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=f"Node '{node_id}' has invalid type '{node_type}'",
704
+ message=msg,
626
705
  severity="error",
627
706
  category="node",
628
707
  location=node_id,
629
- suggested_fix="Normalize node type to valid value",
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=f"Node '{node_id}' has invalid status '{status}'",
723
+ message=msg,
641
724
  severity="error",
642
725
  category="node",
643
726
  location=node_id,
644
- suggested_fix="Normalize status to pending/in_progress/completed/blocked",
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(hierarchy: Dict[str, Any], result: ValidationResult) -> None:
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 'test' or 'fidelity'",
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=f"Verify node '{node_id}' verification_type must be 'test' or 'fidelity'",
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
- task_category = metadata.get("task_category", "implementation")
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
- "task_category" in metadata
884
- and task_category not in VALID_TASK_CATEGORIES
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=f"Task node '{node_id}' has invalid task_category '{task_category}'",
1015
+ message=msg,
890
1016
  severity="error",
891
1017
  category="metadata",
892
1018
  location=node_id,
893
- suggested_fix=f"Set task_category to one of: {', '.join(VALID_TASK_CATEGORIES)}",
894
- auto_fixable=True,
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
- # file_path required for implementation and refactoring
899
- if task_category in ["implementation", "refactoring"]:
900
- if "file_path" not in metadata:
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 '{task_category}' missing metadata.file_path",
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="Add metadata.file_path for implementation tasks",
909
- auto_fixable=True,
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 == "MISSING_FILE_PATH":
991
- return _build_file_path_fix(diag, hierarchy)
1217
+ if code == "INVALID_VERIFICATION_TYPE":
1218
+ return _build_invalid_verification_type_fix(diag, hierarchy)
992
1219
 
993
- if code == "INVALID_TASK_CATEGORY":
994
- return _build_task_category_fix(diag, hierarchy)
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
- node["parent"] = "spec-root"
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 field in ["blocks", "blocked_by", "depends"]:
1266
- blocker_deps.setdefault(field, [])
1267
- blocked_deps.setdefault(field, [])
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"] = "test"
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 'test' for {node_id}",
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 'test' for {node_id}",
1597
+ preview=f"Set verification_type to 'run-tests' for {node_id}",
1337
1598
  apply=apply,
1338
1599
  )
1339
1600
 
1340
1601
 
1341
- def _build_file_path_fix(
1602
+ def _build_invalid_verification_type_fix(
1342
1603
  diag: Diagnostic, hierarchy: Dict[str, Any]
1343
1604
  ) -> Optional[FixAction]:
1344
- """Build fix for missing file path."""
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.setdefault("metadata", {})
1355
- if "file_path" not in metadata:
1356
- metadata["file_path"] = f"{node_id}.py" # Default placeholder
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.add_file_path:{node_id}",
1360
- description=f"Add placeholder file_path for {node_id}",
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"Add placeholder file_path for {node_id}",
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(