foundry-mcp 0.3.3__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 (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +123 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,255 @@
1
+ """
2
+ Pagination utilities for MCP tool operations.
3
+
4
+ Provides cursor-based pagination with opaque cursors, encoding/decoding,
5
+ and response formatting helpers for list-style operations.
6
+
7
+ Pagination Defaults
8
+ ===================
9
+
10
+ Use these constants for consistent pagination across tools:
11
+
12
+ DEFAULT_PAGE_SIZE (100) - Default number of items per page
13
+ MAX_PAGE_SIZE (1000) - Maximum allowed page size
14
+
15
+ Example usage:
16
+
17
+ from foundry_mcp.core.pagination import (
18
+ DEFAULT_PAGE_SIZE,
19
+ MAX_PAGE_SIZE,
20
+ encode_cursor,
21
+ decode_cursor,
22
+ paginated_response,
23
+ )
24
+
25
+ @mcp.tool()
26
+ def list_items(cursor: str = None, limit: int = DEFAULT_PAGE_SIZE) -> dict:
27
+ limit = min(max(1, limit), MAX_PAGE_SIZE)
28
+
29
+ # Decode cursor if provided
30
+ start_after = None
31
+ if cursor:
32
+ cursor_data = decode_cursor(cursor)
33
+ start_after = cursor_data.get("last_id")
34
+
35
+ # Fetch items (one extra to detect has_more)
36
+ items = db.list_items(start_after=start_after, limit=limit + 1)
37
+ has_more = len(items) > limit
38
+ if has_more:
39
+ items = items[:limit]
40
+
41
+ # Build response with pagination
42
+ next_cursor = None
43
+ if has_more and items:
44
+ next_cursor = encode_cursor({"last_id": items[-1]["id"]})
45
+
46
+ return paginated_response(
47
+ data={"items": items},
48
+ cursor=next_cursor,
49
+ has_more=has_more,
50
+ page_size=limit,
51
+ )
52
+ """
53
+
54
+ import base64
55
+ import json
56
+ from dataclasses import asdict
57
+ from typing import Any, Dict, Optional
58
+
59
+ from foundry_mcp.core.responses import success_response
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Pagination Constants
64
+ # ---------------------------------------------------------------------------
65
+
66
+ #: Default number of items per page
67
+ DEFAULT_PAGE_SIZE: int = 100
68
+
69
+ #: Maximum allowed page size
70
+ MAX_PAGE_SIZE: int = 1000
71
+
72
+ #: Cursor format version (for future compatibility)
73
+ CURSOR_VERSION: int = 1
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Cursor Encoding/Decoding
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class CursorError(Exception):
82
+ """Error during cursor encoding or decoding.
83
+
84
+ Attributes:
85
+ cursor: The invalid cursor string (if decoding).
86
+ reason: Description of what went wrong.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ message: str,
92
+ cursor: Optional[str] = None,
93
+ reason: Optional[str] = None,
94
+ ):
95
+ super().__init__(message)
96
+ self.cursor = cursor
97
+ self.reason = reason
98
+
99
+
100
+ def encode_cursor(data: Dict[str, Any]) -> str:
101
+ """Encode cursor data as opaque Base64 token.
102
+
103
+ The cursor is a URL-safe Base64-encoded JSON object containing
104
+ position information for resuming pagination.
105
+
106
+ Args:
107
+ data: Dictionary containing cursor data (typically last_id,
108
+ timestamp, or other position markers).
109
+
110
+ Returns:
111
+ Opaque cursor string (URL-safe Base64 encoded).
112
+
113
+ Example:
114
+ >>> cursor = encode_cursor({"last_id": "item_123"})
115
+ >>> # Returns: "eyJsYXN0X2lkIjogIml0ZW1fMTIzIiwgInZlcnNpb24iOiAxfQ=="
116
+ """
117
+ # Add version for future format migrations
118
+ cursor_data = {**data, "version": CURSOR_VERSION}
119
+ json_str = json.dumps(cursor_data, separators=(",", ":"))
120
+ return base64.urlsafe_b64encode(json_str.encode()).decode()
121
+
122
+
123
+ def decode_cursor(cursor: str) -> Dict[str, Any]:
124
+ """Decode cursor token to dictionary.
125
+
126
+ Args:
127
+ cursor: Opaque cursor string from previous response.
128
+
129
+ Returns:
130
+ Dictionary with cursor data including position markers.
131
+
132
+ Raises:
133
+ CursorError: If cursor is invalid or cannot be decoded.
134
+
135
+ Example:
136
+ >>> data = decode_cursor("eyJsYXN0X2lkIjogIml0ZW1fMTIzIiwgInZlcnNpb24iOiAxfQ==")
137
+ >>> print(data["last_id"])
138
+ "item_123"
139
+ """
140
+ if not cursor:
141
+ raise CursorError("Cursor cannot be empty", cursor=cursor, reason="empty")
142
+
143
+ try:
144
+ decoded_bytes = base64.urlsafe_b64decode(cursor.encode())
145
+ data = json.loads(decoded_bytes.decode())
146
+
147
+ if not isinstance(data, dict):
148
+ raise CursorError(
149
+ "Invalid cursor format",
150
+ cursor=cursor,
151
+ reason="not_a_dict",
152
+ )
153
+
154
+ return data
155
+
156
+ except (ValueError, json.JSONDecodeError) as e:
157
+ raise CursorError(
158
+ f"Failed to decode cursor: {str(e)}",
159
+ cursor=cursor,
160
+ reason="decode_failed",
161
+ )
162
+
163
+
164
+ def validate_cursor(cursor: str) -> bool:
165
+ """Check if cursor is valid without raising exceptions.
166
+
167
+ Args:
168
+ cursor: Cursor string to validate.
169
+
170
+ Returns:
171
+ True if cursor is valid, False otherwise.
172
+ """
173
+ try:
174
+ decode_cursor(cursor)
175
+ return True
176
+ except CursorError:
177
+ return False
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Pagination Response Helper
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def paginated_response(
186
+ data: Dict[str, Any],
187
+ cursor: Optional[str] = None,
188
+ has_more: bool = False,
189
+ page_size: int = DEFAULT_PAGE_SIZE,
190
+ total_count: Optional[int] = None,
191
+ **kwargs: Any,
192
+ ) -> Dict[str, Any]:
193
+ """Create a success response with pagination metadata.
194
+
195
+ Wraps the data in a standard MCP response envelope with
196
+ meta.pagination containing cursor and pagination info.
197
+
198
+ Args:
199
+ data: Response data (typically contains items list).
200
+ cursor: Next page cursor (None if no more pages).
201
+ has_more: Whether more items exist after this page.
202
+ page_size: Number of items in this page.
203
+ total_count: Total count of items (optional, only if efficient).
204
+ **kwargs: Additional arguments passed to success_response.
205
+
206
+ Returns:
207
+ Dict formatted as MCP response with pagination metadata.
208
+
209
+ Example:
210
+ >>> response = paginated_response(
211
+ ... data={"items": [...]},
212
+ ... cursor="abc123",
213
+ ... has_more=True,
214
+ ... page_size=100,
215
+ ... )
216
+ >>> # Response includes meta.pagination with cursor, has_more, etc.
217
+ """
218
+ pagination = {
219
+ "cursor": cursor,
220
+ "has_more": has_more,
221
+ "page_size": page_size,
222
+ }
223
+
224
+ if total_count is not None:
225
+ pagination["total_count"] = total_count
226
+
227
+ return asdict(success_response(data=data, pagination=pagination, **kwargs))
228
+
229
+
230
+ def normalize_page_size(
231
+ requested: Optional[int],
232
+ default: int = DEFAULT_PAGE_SIZE,
233
+ maximum: int = MAX_PAGE_SIZE,
234
+ ) -> int:
235
+ """Normalize requested page size to valid range.
236
+
237
+ Args:
238
+ requested: Requested page size (may be None or out of range).
239
+ default: Default page size if None provided.
240
+ maximum: Maximum allowed page size.
241
+
242
+ Returns:
243
+ Valid page size between 1 and maximum.
244
+
245
+ Example:
246
+ >>> normalize_page_size(None)
247
+ 100
248
+ >>> normalize_page_size(5000)
249
+ 1000
250
+ >>> normalize_page_size(-1)
251
+ 1
252
+ """
253
+ if requested is None:
254
+ return default
255
+ return min(max(1, requested), maximum)
@@ -0,0 +1,317 @@
1
+ """
2
+ Progress calculation utilities for SDD JSON specs.
3
+ Provides hierarchical progress recalculation and status updates.
4
+ """
5
+
6
+ from datetime import datetime, timezone
7
+ from typing import Dict, List, Any
8
+
9
+
10
+ # Status icons for task visualization
11
+ STATUS_ICONS = {
12
+ "pending": "⏳",
13
+ "in_progress": "🔄",
14
+ "completed": "✅",
15
+ "blocked": "🚫",
16
+ "failed": "❌",
17
+ }
18
+
19
+
20
+ def get_status_icon(status: str) -> str:
21
+ """
22
+ Get icon for a task status.
23
+
24
+ Args:
25
+ status: Task status string
26
+
27
+ Returns:
28
+ Status icon character
29
+ """
30
+ return STATUS_ICONS.get(status, "❓")
31
+
32
+
33
+ def recalculate_progress(spec_data: Dict[str, Any], node_id: str = "spec-root") -> Dict[str, Any]:
34
+ """
35
+ Recursively recalculate progress for a node and all its parents.
36
+
37
+ Modifies spec_data in-place by updating completed_tasks, total_tasks,
38
+ and status fields for the node and all ancestors.
39
+
40
+ Args:
41
+ spec_data: JSON spec file data dictionary
42
+ node_id: Node to start recalculation from (default: spec-root)
43
+
44
+ Returns:
45
+ The modified spec_data dictionary (for convenience/chaining)
46
+ """
47
+ if not spec_data:
48
+ return {}
49
+
50
+ hierarchy = spec_data.get("hierarchy", {})
51
+
52
+ if node_id not in hierarchy:
53
+ return spec_data
54
+
55
+ node = hierarchy[node_id]
56
+ children = node.get("children", [])
57
+
58
+ if not children:
59
+ # Leaf node - set based on own status
60
+ node["completed_tasks"] = 1 if node.get("status") == "completed" else 0
61
+ node["total_tasks"] = 1
62
+ else:
63
+ # Non-leaf node - recursively calculate from children
64
+ total_completed = 0
65
+ total_tasks = 0
66
+
67
+ for child_id in children:
68
+ # Recursively recalculate child first
69
+ recalculate_progress(spec_data, child_id)
70
+
71
+ child = hierarchy.get(child_id, {})
72
+ total_completed += child.get("completed_tasks", 0)
73
+ total_tasks += child.get("total_tasks", 0)
74
+
75
+ node["completed_tasks"] = total_completed
76
+ node["total_tasks"] = total_tasks
77
+
78
+ # Update node status based on progress
79
+ update_node_status(node, hierarchy)
80
+
81
+ return spec_data
82
+
83
+
84
+ def update_node_status(node: Dict[str, Any], hierarchy: Dict[str, Any] = None) -> None:
85
+ """
86
+ Update a node's status based on its children's progress.
87
+
88
+ Modifies node in-place. Does not affect manually set statuses
89
+ for leaf nodes (tasks).
90
+
91
+ Args:
92
+ node: Node dictionary from hierarchy
93
+ hierarchy: Full hierarchy dictionary (needed to check child statuses)
94
+ """
95
+ # Don't auto-update status for leaf tasks (they're set manually)
96
+ if node.get("type") == "task" and not node.get("children"):
97
+ return
98
+
99
+ # Track if node is blocked (we'll skip status changes but allow parent updates)
100
+ is_blocked = node.get("status") == "blocked"
101
+
102
+ # Handle manually-completed tasks with children
103
+ if node.get("metadata", {}).get("completed_at") and node.get("children"):
104
+ # Check if actual children progress matches the "completed" state
105
+ actual_completed = node.get("completed_tasks", 0)
106
+ total = node.get("total_tasks", 0)
107
+
108
+ if actual_completed < total:
109
+ # Inconsistent state: parent marked complete but children aren't.
110
+ # Remove completed_at to allow normal status calculation to take over below.
111
+ if "metadata" in node and "completed_at" in node["metadata"]:
112
+ del node["metadata"]["completed_at"]
113
+ else:
114
+ # Consistent state, enforce completion
115
+ node["status"] = "completed"
116
+
117
+ # If blocked, don't change status but continue to allow count updates
118
+ if is_blocked:
119
+ return
120
+
121
+ # Check if any children are in_progress (takes priority over count-based logic)
122
+ if hierarchy and node.get("children"):
123
+ for child_id in node.get("children", []):
124
+ child = hierarchy.get(child_id, {})
125
+ if child.get("status") == "in_progress":
126
+ node["status"] = "in_progress"
127
+ return
128
+
129
+ completed = node.get("completed_tasks", 0)
130
+ total = node.get("total_tasks", 0)
131
+
132
+ if total == 0:
133
+ node["status"] = "pending"
134
+ elif completed == 0:
135
+ node["status"] = "pending"
136
+ elif completed == total:
137
+ # Check if status is changing to completed (auto-completion)
138
+ was_completed = node.get("status") == "completed"
139
+ node["status"] = "completed"
140
+
141
+ # Set needs_journaling flag for parent nodes (groups, phases)
142
+ # when they auto-complete (not manually set)
143
+ if not was_completed and node.get("type") in ["group", "phase"]:
144
+ if "metadata" not in node:
145
+ node["metadata"] = {}
146
+ node["metadata"]["needs_journaling"] = True
147
+ node["metadata"]["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
148
+ else:
149
+ node["status"] = "in_progress"
150
+
151
+
152
+ def update_parent_status(spec_data: Dict[str, Any], node_id: str) -> Dict[str, Any]:
153
+ """
154
+ Update status and progress for a node's parent chain.
155
+
156
+ Use this after updating a task status to propagate changes up the hierarchy.
157
+
158
+ Args:
159
+ spec_data: JSON spec file data dictionary
160
+ node_id: Node whose parents should be updated
161
+
162
+ Returns:
163
+ The modified spec_data dictionary (for convenience/chaining)
164
+ """
165
+ if not spec_data:
166
+ return {}
167
+
168
+ hierarchy = spec_data.get("hierarchy", {})
169
+
170
+ if node_id not in hierarchy:
171
+ return spec_data
172
+
173
+ node = hierarchy[node_id]
174
+ parent_id = node.get("parent")
175
+
176
+ # Walk up the parent chain
177
+ while parent_id and parent_id in hierarchy:
178
+ # Recalculate progress for parent
179
+ recalculate_progress(spec_data, parent_id)
180
+
181
+ # Move to next parent
182
+ parent = hierarchy[parent_id]
183
+ parent_id = parent.get("parent")
184
+
185
+ return spec_data
186
+
187
+
188
+ def get_progress_summary(spec_data: Dict[str, Any], node_id: str = "spec-root") -> Dict[str, Any]:
189
+ """
190
+ Get progress summary for a node.
191
+
192
+ Args:
193
+ spec_data: JSON spec file data
194
+ node_id: Node to get progress for (default: spec-root)
195
+
196
+ Returns:
197
+ Dictionary with progress information
198
+ """
199
+ if not spec_data:
200
+ return {"error": "No state data provided"}
201
+
202
+ # Recalculate progress to ensure counts are up-to-date
203
+ recalculate_progress(spec_data, node_id)
204
+
205
+ hierarchy = spec_data.get("hierarchy", {})
206
+ node = hierarchy.get(node_id)
207
+
208
+ if not node:
209
+ return {"error": f"Node {node_id} not found"}
210
+
211
+ total = node.get("total_tasks", 0)
212
+ completed = node.get("completed_tasks", 0)
213
+ percentage = int((completed / total * 100)) if total > 0 else 0
214
+
215
+ # Extract spec_id from spec_data
216
+ spec_id = spec_data.get("spec_id", "")
217
+
218
+ # Find current phase (first in_progress, or first pending if none)
219
+ current_phase = None
220
+ for key, value in hierarchy.items():
221
+ if value.get("type") == "phase":
222
+ if value.get("status") == "in_progress":
223
+ current_phase = {
224
+ "id": key,
225
+ "title": value.get("title", ""),
226
+ "completed": value.get("completed_tasks", 0),
227
+ "total": value.get("total_tasks", 0)
228
+ }
229
+ break
230
+ elif current_phase is None and value.get("status") == "pending":
231
+ current_phase = {
232
+ "id": key,
233
+ "title": value.get("title", ""),
234
+ "completed": value.get("completed_tasks", 0),
235
+ "total": value.get("total_tasks", 0)
236
+ }
237
+
238
+ return {
239
+ "node_id": node_id,
240
+ "spec_id": spec_id,
241
+ "title": node.get("title", ""),
242
+ "type": node.get("type", ""),
243
+ "status": node.get("status", ""),
244
+ "total_tasks": total,
245
+ "completed_tasks": completed,
246
+ "percentage": percentage,
247
+ "remaining_tasks": total - completed,
248
+ "current_phase": current_phase
249
+ }
250
+
251
+
252
+ def list_phases(spec_data: Dict[str, Any]) -> List[Dict[str, Any]]:
253
+ """
254
+ List all phases with their status and progress.
255
+
256
+ Args:
257
+ spec_data: JSON spec file data
258
+
259
+ Returns:
260
+ List of phase dictionaries
261
+ """
262
+ if not spec_data:
263
+ return []
264
+
265
+ hierarchy = spec_data.get("hierarchy", {})
266
+
267
+ phases = []
268
+ for key, value in hierarchy.items():
269
+ if value.get("type") == "phase":
270
+ total = value.get("total_tasks", 0)
271
+ completed = value.get("completed_tasks", 0)
272
+ percentage = int((completed / total * 100)) if total > 0 else 0
273
+
274
+ phases.append({
275
+ "id": key,
276
+ "title": value.get("title", ""),
277
+ "status": value.get("status", ""),
278
+ "completed_tasks": completed,
279
+ "total_tasks": total,
280
+ "percentage": percentage
281
+ })
282
+
283
+ # Sort by phase ID (phase-1, phase-2, etc.)
284
+ phases.sort(key=lambda p: p["id"])
285
+
286
+ return phases
287
+
288
+
289
+ def get_task_counts_by_status(spec_data: Dict[str, Any]) -> Dict[str, int]:
290
+ """
291
+ Count tasks by their status.
292
+
293
+ Args:
294
+ spec_data: JSON spec file data
295
+
296
+ Returns:
297
+ Dictionary mapping status to count
298
+ """
299
+ if not spec_data:
300
+ return {"pending": 0, "in_progress": 0, "completed": 0, "blocked": 0}
301
+
302
+ hierarchy = spec_data.get("hierarchy", {})
303
+
304
+ counts = {
305
+ "pending": 0,
306
+ "in_progress": 0,
307
+ "completed": 0,
308
+ "blocked": 0
309
+ }
310
+
311
+ for node in hierarchy.values():
312
+ if node.get("type") == "task":
313
+ status = node.get("status", "pending")
314
+ if status in counts:
315
+ counts[status] += 1
316
+
317
+ return counts