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.
- foundry_mcp/__init__.py +7 -0
- foundry_mcp/cli/__init__.py +80 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +633 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +652 -0
- foundry_mcp/cli/commands/session.py +479 -0
- foundry_mcp/cli/commands/specs.py +856 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +259 -0
- foundry_mcp/cli/flags.py +266 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +850 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1636 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/feature_flags.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/journal.py +694 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1350 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +123 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +317 -0
- foundry_mcp/core/prometheus.py +577 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +546 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
- foundry_mcp/core/prompts/plan_review.py +623 -0
- foundry_mcp/core/providers/__init__.py +225 -0
- foundry_mcp/core/providers/base.py +476 -0
- foundry_mcp/core/providers/claude.py +460 -0
- foundry_mcp/core/providers/codex.py +619 -0
- foundry_mcp/core/providers/cursor_agent.py +642 -0
- foundry_mcp/core/providers/detectors.py +488 -0
- foundry_mcp/core/providers/gemini.py +405 -0
- foundry_mcp/core/providers/opencode.py +616 -0
- foundry_mcp/core/providers/opencode_wrapper.js +302 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +729 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +934 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +1650 -0
- foundry_mcp/core/task.py +1289 -0
- foundry_mcp/core/testing.py +450 -0
- foundry_mcp/core/validation.py +2081 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +234 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +289 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +174 -0
- foundry_mcp/dashboard/views/overview.py +160 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/sdd-spec-schema.json +386 -0
- foundry_mcp/server.py +164 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +71 -0
- foundry_mcp/tools/unified/authoring.py +1487 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +198 -0
- foundry_mcp/tools/unified/environment.py +939 -0
- foundry_mcp/tools/unified/error.py +462 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +632 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +745 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +629 -0
- foundry_mcp/tools/unified/review.py +685 -0
- foundry_mcp/tools/unified/review_helpers.py +299 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +580 -0
- foundry_mcp/tools/unified/spec.py +808 -0
- foundry_mcp/tools/unified/task.py +2202 -0
- foundry_mcp/tools/unified/test.py +370 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.3.3.dist-info/METADATA +337 -0
- foundry_mcp-0.3.3.dist-info/RECORD +135 -0
- foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
- foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
- 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
|