foundry-mcp 0.8.22__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.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -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 +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -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 +298 -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 +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -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/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -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 +146 -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 +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -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 +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -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 +177 -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 +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -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/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lifecycle operations for SDD spec files.
|
|
3
|
+
Provides spec status transitions: move, activate, complete, archive.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Data structures
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class MoveResult:
|
|
18
|
+
"""
|
|
19
|
+
Result of moving a spec between status folders.
|
|
20
|
+
"""
|
|
21
|
+
success: bool
|
|
22
|
+
spec_id: str
|
|
23
|
+
from_folder: str
|
|
24
|
+
to_folder: str
|
|
25
|
+
old_path: Optional[str] = None
|
|
26
|
+
new_path: Optional[str] = None
|
|
27
|
+
error: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class LifecycleState:
|
|
32
|
+
"""
|
|
33
|
+
Current lifecycle state of a spec.
|
|
34
|
+
"""
|
|
35
|
+
spec_id: str
|
|
36
|
+
folder: str # pending, active, completed, archived
|
|
37
|
+
status: str # from spec-root
|
|
38
|
+
progress_percentage: float
|
|
39
|
+
total_tasks: int
|
|
40
|
+
completed_tasks: int
|
|
41
|
+
can_complete: bool
|
|
42
|
+
can_archive: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Constants
|
|
46
|
+
|
|
47
|
+
VALID_FOLDERS = {"pending", "active", "completed", "archived"}
|
|
48
|
+
|
|
49
|
+
FOLDER_TRANSITIONS = {
|
|
50
|
+
"pending": ["active", "archived"],
|
|
51
|
+
"active": ["pending", "completed", "archived"],
|
|
52
|
+
"completed": ["active", "archived"],
|
|
53
|
+
"archived": ["pending", "active"],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Main lifecycle functions
|
|
58
|
+
|
|
59
|
+
def move_spec(
|
|
60
|
+
spec_id: str,
|
|
61
|
+
to_folder: str,
|
|
62
|
+
specs_dir: Path,
|
|
63
|
+
update_status: bool = True,
|
|
64
|
+
) -> MoveResult:
|
|
65
|
+
"""
|
|
66
|
+
Move a spec between status folders.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
spec_id: Specification ID
|
|
70
|
+
to_folder: Target folder (pending, active, completed, archived)
|
|
71
|
+
specs_dir: Path to specs directory
|
|
72
|
+
update_status: Whether to update spec-root status to match folder
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
MoveResult with operation outcome
|
|
76
|
+
"""
|
|
77
|
+
if to_folder not in VALID_FOLDERS:
|
|
78
|
+
return MoveResult(
|
|
79
|
+
success=False,
|
|
80
|
+
spec_id=spec_id,
|
|
81
|
+
from_folder="",
|
|
82
|
+
to_folder=to_folder,
|
|
83
|
+
error=f"Invalid folder: {to_folder}. Must be one of: {VALID_FOLDERS}",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Find current location
|
|
87
|
+
current_folder, current_path = _find_spec_location(spec_id, specs_dir)
|
|
88
|
+
|
|
89
|
+
if not current_path:
|
|
90
|
+
return MoveResult(
|
|
91
|
+
success=False,
|
|
92
|
+
spec_id=spec_id,
|
|
93
|
+
from_folder="",
|
|
94
|
+
to_folder=to_folder,
|
|
95
|
+
error=f"Spec not found: {spec_id}",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if current_folder == to_folder:
|
|
99
|
+
return MoveResult(
|
|
100
|
+
success=True,
|
|
101
|
+
spec_id=spec_id,
|
|
102
|
+
from_folder=current_folder,
|
|
103
|
+
to_folder=to_folder,
|
|
104
|
+
old_path=str(current_path),
|
|
105
|
+
new_path=str(current_path),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Validate transition
|
|
109
|
+
allowed = FOLDER_TRANSITIONS.get(current_folder, [])
|
|
110
|
+
if to_folder not in allowed:
|
|
111
|
+
return MoveResult(
|
|
112
|
+
success=False,
|
|
113
|
+
spec_id=spec_id,
|
|
114
|
+
from_folder=current_folder,
|
|
115
|
+
to_folder=to_folder,
|
|
116
|
+
error=f"Cannot move from {current_folder} to {to_folder}. Allowed: {allowed}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Ensure target folder exists
|
|
120
|
+
target_dir = specs_dir / to_folder
|
|
121
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
# Calculate new path
|
|
124
|
+
new_path = target_dir / current_path.name
|
|
125
|
+
|
|
126
|
+
# Check for conflicts
|
|
127
|
+
if new_path.exists():
|
|
128
|
+
return MoveResult(
|
|
129
|
+
success=False,
|
|
130
|
+
spec_id=spec_id,
|
|
131
|
+
from_folder=current_folder,
|
|
132
|
+
to_folder=to_folder,
|
|
133
|
+
error=f"Target already exists: {new_path}",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Move the file
|
|
138
|
+
shutil.move(str(current_path), str(new_path))
|
|
139
|
+
|
|
140
|
+
# Update spec status if requested
|
|
141
|
+
if update_status:
|
|
142
|
+
_update_spec_folder_status(new_path, to_folder)
|
|
143
|
+
|
|
144
|
+
return MoveResult(
|
|
145
|
+
success=True,
|
|
146
|
+
spec_id=spec_id,
|
|
147
|
+
from_folder=current_folder,
|
|
148
|
+
to_folder=to_folder,
|
|
149
|
+
old_path=str(current_path),
|
|
150
|
+
new_path=str(new_path),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
except OSError as e:
|
|
154
|
+
return MoveResult(
|
|
155
|
+
success=False,
|
|
156
|
+
spec_id=spec_id,
|
|
157
|
+
from_folder=current_folder,
|
|
158
|
+
to_folder=to_folder,
|
|
159
|
+
error=f"Failed to move file: {e}",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def activate_spec(spec_id: str, specs_dir: Path) -> MoveResult:
|
|
164
|
+
"""
|
|
165
|
+
Activate a spec (move from pending to active).
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
spec_id: Specification ID
|
|
169
|
+
specs_dir: Path to specs directory
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
MoveResult with operation outcome
|
|
173
|
+
"""
|
|
174
|
+
return move_spec(spec_id, "active", specs_dir)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def complete_spec(
|
|
178
|
+
spec_id: str,
|
|
179
|
+
specs_dir: Path,
|
|
180
|
+
force: bool = False,
|
|
181
|
+
) -> MoveResult:
|
|
182
|
+
"""
|
|
183
|
+
Mark a spec as completed (move to completed folder).
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
spec_id: Specification ID
|
|
187
|
+
specs_dir: Path to specs directory
|
|
188
|
+
force: Force completion even if tasks are incomplete
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
MoveResult with operation outcome
|
|
192
|
+
"""
|
|
193
|
+
# Check if spec can be completed
|
|
194
|
+
if not force:
|
|
195
|
+
state = get_lifecycle_state(spec_id, specs_dir)
|
|
196
|
+
if state and not state.can_complete:
|
|
197
|
+
return MoveResult(
|
|
198
|
+
success=False,
|
|
199
|
+
spec_id=spec_id,
|
|
200
|
+
from_folder=state.folder,
|
|
201
|
+
to_folder="completed",
|
|
202
|
+
error=f"Cannot complete spec: {state.completed_tasks}/{state.total_tasks} tasks done ({state.progress_percentage:.0f}%)",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return move_spec(spec_id, "completed", specs_dir)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def archive_spec(spec_id: str, specs_dir: Path) -> MoveResult:
|
|
209
|
+
"""
|
|
210
|
+
Archive a spec (move to archived folder).
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
spec_id: Specification ID
|
|
214
|
+
specs_dir: Path to specs directory
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
MoveResult with operation outcome
|
|
218
|
+
"""
|
|
219
|
+
return move_spec(spec_id, "archived", specs_dir)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_lifecycle_state(spec_id: str, specs_dir: Path) -> Optional[LifecycleState]:
|
|
223
|
+
"""
|
|
224
|
+
Get the current lifecycle state of a spec.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
spec_id: Specification ID
|
|
228
|
+
specs_dir: Path to specs directory
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
LifecycleState or None if spec not found
|
|
232
|
+
"""
|
|
233
|
+
folder, path = _find_spec_location(spec_id, specs_dir)
|
|
234
|
+
|
|
235
|
+
if not path:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
with open(path, "r") as f:
|
|
240
|
+
spec_data = json.load(f)
|
|
241
|
+
except (OSError, json.JSONDecodeError):
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
hierarchy = spec_data.get("hierarchy", {})
|
|
245
|
+
root = hierarchy.get("spec-root", {})
|
|
246
|
+
|
|
247
|
+
total_tasks = root.get("total_tasks", 0)
|
|
248
|
+
completed_tasks = root.get("completed_tasks", 0)
|
|
249
|
+
status = root.get("status", "pending")
|
|
250
|
+
|
|
251
|
+
progress = (completed_tasks / total_tasks * 100) if total_tasks > 0 else 0
|
|
252
|
+
|
|
253
|
+
# Can complete if all tasks done or in active folder with 100% progress
|
|
254
|
+
can_complete = progress >= 100 or status == "completed"
|
|
255
|
+
|
|
256
|
+
# Can archive from any folder
|
|
257
|
+
can_archive = True
|
|
258
|
+
|
|
259
|
+
return LifecycleState(
|
|
260
|
+
spec_id=spec_id,
|
|
261
|
+
folder=folder,
|
|
262
|
+
status=status,
|
|
263
|
+
progress_percentage=progress,
|
|
264
|
+
total_tasks=total_tasks,
|
|
265
|
+
completed_tasks=completed_tasks,
|
|
266
|
+
can_complete=can_complete,
|
|
267
|
+
can_archive=can_archive,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def list_specs_by_folder(
|
|
272
|
+
specs_dir: Path,
|
|
273
|
+
folder: Optional[str] = None,
|
|
274
|
+
) -> Dict[str, List[Dict[str, Any]]]:
|
|
275
|
+
"""
|
|
276
|
+
List specs organized by folder.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
specs_dir: Path to specs directory
|
|
280
|
+
folder: Optional filter to specific folder
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Dict mapping folder names to lists of spec summaries
|
|
284
|
+
"""
|
|
285
|
+
result: Dict[str, List[Dict[str, Any]]] = {}
|
|
286
|
+
|
|
287
|
+
folders = [folder] if folder else list(VALID_FOLDERS)
|
|
288
|
+
|
|
289
|
+
for f in folders:
|
|
290
|
+
folder_path = specs_dir / f
|
|
291
|
+
if not folder_path.exists():
|
|
292
|
+
result[f] = []
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
specs = []
|
|
296
|
+
for spec_file in folder_path.glob("*.json"):
|
|
297
|
+
try:
|
|
298
|
+
with open(spec_file, "r") as file:
|
|
299
|
+
data = json.load(file)
|
|
300
|
+
|
|
301
|
+
hierarchy = data.get("hierarchy", {})
|
|
302
|
+
root = hierarchy.get("spec-root", {})
|
|
303
|
+
metadata = data.get("metadata", {})
|
|
304
|
+
|
|
305
|
+
total = root.get("total_tasks", 0)
|
|
306
|
+
completed = root.get("completed_tasks", 0)
|
|
307
|
+
|
|
308
|
+
specs.append({
|
|
309
|
+
"spec_id": data.get("spec_id", spec_file.stem),
|
|
310
|
+
"title": metadata.get("title") or root.get("title", "Untitled"),
|
|
311
|
+
"status": root.get("status", "pending"),
|
|
312
|
+
"total_tasks": total,
|
|
313
|
+
"completed_tasks": completed,
|
|
314
|
+
"progress": (completed / total * 100) if total > 0 else 0,
|
|
315
|
+
"path": str(spec_file),
|
|
316
|
+
})
|
|
317
|
+
except (OSError, json.JSONDecodeError):
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
result[f] = specs
|
|
321
|
+
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def get_folder_for_spec(spec_id: str, specs_dir: Path) -> Optional[str]:
|
|
326
|
+
"""
|
|
327
|
+
Get the folder where a spec is located.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
spec_id: Specification ID
|
|
331
|
+
specs_dir: Path to specs directory
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
Folder name or None if not found
|
|
335
|
+
"""
|
|
336
|
+
folder, _ = _find_spec_location(spec_id, specs_dir)
|
|
337
|
+
return folder
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# Helper functions
|
|
341
|
+
|
|
342
|
+
def _find_spec_location(
|
|
343
|
+
spec_id: str,
|
|
344
|
+
specs_dir: Path,
|
|
345
|
+
) -> tuple[Optional[str], Optional[Path]]:
|
|
346
|
+
"""
|
|
347
|
+
Find which folder contains a spec.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Tuple of (folder_name, path) or (None, None) if not found
|
|
351
|
+
"""
|
|
352
|
+
for folder in VALID_FOLDERS:
|
|
353
|
+
folder_path = specs_dir / folder
|
|
354
|
+
if not folder_path.exists():
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# Check for exact match
|
|
358
|
+
spec_path = folder_path / f"{spec_id}.json"
|
|
359
|
+
if spec_path.exists():
|
|
360
|
+
return folder, spec_path
|
|
361
|
+
|
|
362
|
+
# Check for files that contain the spec_id
|
|
363
|
+
for f in folder_path.glob("*.json"):
|
|
364
|
+
try:
|
|
365
|
+
with open(f, "r") as file:
|
|
366
|
+
data = json.load(file)
|
|
367
|
+
if data.get("spec_id") == spec_id:
|
|
368
|
+
return folder, f
|
|
369
|
+
except (OSError, json.JSONDecodeError):
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
return None, None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _update_spec_folder_status(path: Path, folder: str) -> bool:
|
|
376
|
+
"""
|
|
377
|
+
Update spec-root status to match folder.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
path: Path to spec file
|
|
381
|
+
folder: Folder name (maps to status)
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
True if update successful
|
|
385
|
+
"""
|
|
386
|
+
folder_to_status = {
|
|
387
|
+
"pending": "pending",
|
|
388
|
+
"active": "in_progress",
|
|
389
|
+
"completed": "completed",
|
|
390
|
+
"archived": "completed",
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
new_status = folder_to_status.get(folder, "pending")
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
with open(path, "r") as f:
|
|
397
|
+
data = json.load(f)
|
|
398
|
+
|
|
399
|
+
hierarchy = data.get("hierarchy", {})
|
|
400
|
+
if "spec-root" in hierarchy:
|
|
401
|
+
hierarchy["spec-root"]["status"] = new_status
|
|
402
|
+
|
|
403
|
+
# Update last_updated
|
|
404
|
+
data["last_updated"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
405
|
+
|
|
406
|
+
with open(path, "w") as f:
|
|
407
|
+
json.dump(data, f, indent=2)
|
|
408
|
+
|
|
409
|
+
return True
|
|
410
|
+
|
|
411
|
+
except (OSError, json.JSONDecodeError):
|
|
412
|
+
return False
|