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,686 @@
|
|
|
1
|
+
"""Spec management commands for SDD CLI.
|
|
2
|
+
|
|
3
|
+
Provides commands for creating, listing, and managing specifications.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
from foundry_mcp.cli.logging import cli_command, get_cli_logger
|
|
15
|
+
from foundry_mcp.cli.output import emit_error, emit_success
|
|
16
|
+
from foundry_mcp.cli.registry import get_context
|
|
17
|
+
from foundry_mcp.cli.resilience import (
|
|
18
|
+
FAST_TIMEOUT,
|
|
19
|
+
handle_keyboard_interrupt,
|
|
20
|
+
MEDIUM_TIMEOUT,
|
|
21
|
+
with_sync_timeout,
|
|
22
|
+
)
|
|
23
|
+
from foundry_mcp.core.journal import list_blocked_tasks
|
|
24
|
+
from foundry_mcp.core.progress import list_phases as core_list_phases
|
|
25
|
+
from foundry_mcp.core.spec import list_specs as core_list_specs, load_spec
|
|
26
|
+
|
|
27
|
+
logger = get_cli_logger()
|
|
28
|
+
|
|
29
|
+
# Valid templates and categories
|
|
30
|
+
# Note: Only 'empty' template is supported. Use phase templates to add structure.
|
|
31
|
+
TEMPLATES = ("empty",)
|
|
32
|
+
CATEGORIES = ("investigation", "implementation", "refactoring", "decision", "research")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_spec_id(name: str) -> str:
|
|
36
|
+
"""Generate a spec ID from a name.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
name: Human-readable spec name.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
URL-safe spec ID with date suffix.
|
|
43
|
+
"""
|
|
44
|
+
# Normalize: lowercase, replace spaces/special chars with hyphens
|
|
45
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
|
46
|
+
# Add date suffix
|
|
47
|
+
date_suffix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
48
|
+
# Add sequence number (001 for new specs)
|
|
49
|
+
return f"{slug}-{date_suffix}-001"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_template_structure(template: str, category: str) -> Dict[str, Any]:
|
|
53
|
+
"""Get the hierarchical structure for a spec template.
|
|
54
|
+
|
|
55
|
+
Only 'empty' template is supported. Use phase templates to add structure.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
template: Template type (only 'empty' is valid).
|
|
59
|
+
category: Default task category.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Hierarchy dict for the spec.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If template is not 'empty'.
|
|
66
|
+
"""
|
|
67
|
+
if template != "empty":
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Invalid template '{template}'. Only 'empty' template is supported. "
|
|
70
|
+
f"Use phase templates to add structure."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
"spec-root": {
|
|
75
|
+
"type": "spec",
|
|
76
|
+
"title": "", # Filled in later
|
|
77
|
+
"status": "pending",
|
|
78
|
+
"parent": None,
|
|
79
|
+
"children": [],
|
|
80
|
+
"total_tasks": 0,
|
|
81
|
+
"completed_tasks": 0,
|
|
82
|
+
"metadata": {
|
|
83
|
+
"purpose": "",
|
|
84
|
+
"category": category,
|
|
85
|
+
},
|
|
86
|
+
"dependencies": {
|
|
87
|
+
"blocks": [],
|
|
88
|
+
"blocked_by": [],
|
|
89
|
+
"depends": [],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@click.group("specs")
|
|
96
|
+
def specs() -> None:
|
|
97
|
+
"""Specification management commands."""
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Template definitions for listing/showing
|
|
102
|
+
TEMPLATE_INFO = {
|
|
103
|
+
"empty": {
|
|
104
|
+
"name": "empty",
|
|
105
|
+
"description": "Blank spec with no phases - use phase templates to add structure",
|
|
106
|
+
"phases": 0,
|
|
107
|
+
"tasks": 0,
|
|
108
|
+
"use_cases": ["All specs - add phases via phase-add-bulk or phase-template apply"],
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Phase templates available for adding structure
|
|
113
|
+
PHASE_TEMPLATES = ("planning", "implementation", "testing", "security", "documentation")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@specs.command("template")
|
|
117
|
+
@click.argument("action", type=click.Choice(["list", "show"]))
|
|
118
|
+
@click.argument("template_name", required=False)
|
|
119
|
+
@click.pass_context
|
|
120
|
+
@cli_command("template")
|
|
121
|
+
@handle_keyboard_interrupt()
|
|
122
|
+
@with_sync_timeout(MEDIUM_TIMEOUT, "Template lookup timed out")
|
|
123
|
+
def template(
|
|
124
|
+
ctx: click.Context,
|
|
125
|
+
action: str,
|
|
126
|
+
template_name: Optional[str] = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""List or show spec templates.
|
|
129
|
+
|
|
130
|
+
ACTION is either 'list' (show all templates) or 'show' (show template details).
|
|
131
|
+
TEMPLATE_NAME is required for 'show' action.
|
|
132
|
+
"""
|
|
133
|
+
if action == "list":
|
|
134
|
+
templates = [
|
|
135
|
+
{
|
|
136
|
+
"name": info["name"],
|
|
137
|
+
"description": info["description"],
|
|
138
|
+
"phases": info["phases"],
|
|
139
|
+
"tasks": info["tasks"],
|
|
140
|
+
}
|
|
141
|
+
for info in TEMPLATE_INFO.values()
|
|
142
|
+
]
|
|
143
|
+
emit_success(
|
|
144
|
+
{
|
|
145
|
+
"templates": templates,
|
|
146
|
+
"count": len(templates),
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
elif action == "show":
|
|
151
|
+
if not template_name:
|
|
152
|
+
emit_error(
|
|
153
|
+
"Template name required for 'show' action",
|
|
154
|
+
code="MISSING_REQUIRED",
|
|
155
|
+
error_type="validation",
|
|
156
|
+
remediation="Provide a template name: sdd specs template show <template_name>",
|
|
157
|
+
details={"required": "template_name"},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if template_name not in TEMPLATE_INFO:
|
|
161
|
+
emit_error(
|
|
162
|
+
f"Unknown template: {template_name}",
|
|
163
|
+
code="NOT_FOUND",
|
|
164
|
+
error_type="not_found",
|
|
165
|
+
remediation=f"Use one of the available templates: {', '.join(TEMPLATE_INFO.keys())}",
|
|
166
|
+
details={
|
|
167
|
+
"template": template_name,
|
|
168
|
+
"available": list(TEMPLATE_INFO.keys()),
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
info = TEMPLATE_INFO[template_name]
|
|
173
|
+
# Get the actual structure
|
|
174
|
+
structure = get_template_structure(template_name, "implementation")
|
|
175
|
+
|
|
176
|
+
emit_success(
|
|
177
|
+
{
|
|
178
|
+
"template": info,
|
|
179
|
+
"structure": {
|
|
180
|
+
"nodes": list(structure.keys()),
|
|
181
|
+
"hierarchy": {
|
|
182
|
+
node_id: {
|
|
183
|
+
"type": node["type"],
|
|
184
|
+
"title": node["title"],
|
|
185
|
+
"children": node.get("children", []),
|
|
186
|
+
}
|
|
187
|
+
for node_id, node in structure.items()
|
|
188
|
+
if isinstance(node, dict)
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@specs.command("analyze")
|
|
196
|
+
@click.argument("directory", required=False)
|
|
197
|
+
@click.pass_context
|
|
198
|
+
@cli_command("analyze")
|
|
199
|
+
@handle_keyboard_interrupt()
|
|
200
|
+
@with_sync_timeout(MEDIUM_TIMEOUT, "Spec analysis timed out")
|
|
201
|
+
def analyze(ctx: click.Context, directory: Optional[str] = None) -> None:
|
|
202
|
+
"""Analyze specs directory structure and health.
|
|
203
|
+
|
|
204
|
+
DIRECTORY is the path to analyze (defaults to current directory).
|
|
205
|
+
"""
|
|
206
|
+
cli_ctx = get_context(ctx)
|
|
207
|
+
target_dir = Path(directory) if directory else Path.cwd()
|
|
208
|
+
|
|
209
|
+
# Check for specs directory
|
|
210
|
+
specs_dir = cli_ctx.specs_dir
|
|
211
|
+
if specs_dir is None:
|
|
212
|
+
# Try to find specs in the target directory
|
|
213
|
+
for subdir in ("specs", "."):
|
|
214
|
+
candidate = target_dir / subdir
|
|
215
|
+
if candidate.is_dir():
|
|
216
|
+
for folder in ("pending", "active", "completed", "archived"):
|
|
217
|
+
if (candidate / folder).is_dir():
|
|
218
|
+
specs_dir = candidate
|
|
219
|
+
break
|
|
220
|
+
if specs_dir:
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
# Gather analysis data
|
|
224
|
+
analysis: Dict[str, Any] = {
|
|
225
|
+
"directory": str(target_dir.resolve()),
|
|
226
|
+
"has_specs": specs_dir is not None,
|
|
227
|
+
"specs_dir": str(specs_dir) if specs_dir else None,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if specs_dir and specs_dir.is_dir():
|
|
231
|
+
# Count specs by folder
|
|
232
|
+
folder_counts = {}
|
|
233
|
+
total_specs = 0
|
|
234
|
+
for folder in ("pending", "active", "completed", "archived"):
|
|
235
|
+
folder_path = specs_dir / folder
|
|
236
|
+
if folder_path.is_dir():
|
|
237
|
+
count = len(list(folder_path.glob("*.json")))
|
|
238
|
+
folder_counts[folder] = count
|
|
239
|
+
total_specs += count
|
|
240
|
+
else:
|
|
241
|
+
folder_counts[folder] = 0
|
|
242
|
+
|
|
243
|
+
analysis["spec_counts"] = folder_counts
|
|
244
|
+
analysis["total_specs"] = total_specs
|
|
245
|
+
|
|
246
|
+
# Check for documentation
|
|
247
|
+
docs_dir = specs_dir / ".human-readable"
|
|
248
|
+
analysis["documentation_available"] = docs_dir.is_dir() and any(
|
|
249
|
+
docs_dir.glob("*.md")
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Check for codebase docs
|
|
253
|
+
codebase_json = target_dir / "docs" / "codebase.json"
|
|
254
|
+
analysis["codebase_docs_available"] = codebase_json.is_file()
|
|
255
|
+
|
|
256
|
+
# Workspace health indicators
|
|
257
|
+
analysis["health"] = {
|
|
258
|
+
"has_active_specs": folder_counts.get("active", 0) > 0,
|
|
259
|
+
"has_pending_specs": folder_counts.get("pending", 0) > 0,
|
|
260
|
+
"completion_rate": (
|
|
261
|
+
round(folder_counts.get("completed", 0) / total_specs * 100, 1)
|
|
262
|
+
if total_specs > 0
|
|
263
|
+
else 0
|
|
264
|
+
),
|
|
265
|
+
}
|
|
266
|
+
else:
|
|
267
|
+
analysis["spec_counts"] = None
|
|
268
|
+
analysis["total_specs"] = 0
|
|
269
|
+
analysis["documentation_available"] = False
|
|
270
|
+
analysis["codebase_docs_available"] = False
|
|
271
|
+
analysis["health"] = None
|
|
272
|
+
|
|
273
|
+
emit_success(analysis)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@specs.command("create")
|
|
277
|
+
@click.argument("name")
|
|
278
|
+
@click.option(
|
|
279
|
+
"--template",
|
|
280
|
+
type=click.Choice(TEMPLATES),
|
|
281
|
+
default="empty",
|
|
282
|
+
help="Spec template (only 'empty' supported - use phase templates to add structure).",
|
|
283
|
+
)
|
|
284
|
+
@click.option(
|
|
285
|
+
"--category",
|
|
286
|
+
type=click.Choice(CATEGORIES),
|
|
287
|
+
default="implementation",
|
|
288
|
+
help="Default task category.",
|
|
289
|
+
)
|
|
290
|
+
@click.option(
|
|
291
|
+
"--mission",
|
|
292
|
+
type=str,
|
|
293
|
+
default="",
|
|
294
|
+
help="Optional mission statement for the spec.",
|
|
295
|
+
)
|
|
296
|
+
@click.pass_context
|
|
297
|
+
@cli_command("create")
|
|
298
|
+
@handle_keyboard_interrupt()
|
|
299
|
+
@with_sync_timeout(MEDIUM_TIMEOUT, "Spec creation timed out")
|
|
300
|
+
def create(
|
|
301
|
+
ctx: click.Context,
|
|
302
|
+
name: str,
|
|
303
|
+
template: str,
|
|
304
|
+
category: str,
|
|
305
|
+
mission: str,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Create a new specification.
|
|
308
|
+
|
|
309
|
+
NAME is the human-readable name for the specification.
|
|
310
|
+
"""
|
|
311
|
+
cli_ctx = get_context(ctx)
|
|
312
|
+
specs_dir = cli_ctx.specs_dir
|
|
313
|
+
|
|
314
|
+
if specs_dir is None:
|
|
315
|
+
emit_error(
|
|
316
|
+
"No specs directory found",
|
|
317
|
+
code="VALIDATION_ERROR",
|
|
318
|
+
error_type="validation",
|
|
319
|
+
remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
|
|
320
|
+
details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
# Ensure pending directory exists
|
|
324
|
+
pending_dir = specs_dir / "pending"
|
|
325
|
+
pending_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
|
|
327
|
+
# Generate spec ID
|
|
328
|
+
spec_id = generate_spec_id(name)
|
|
329
|
+
|
|
330
|
+
# Check if spec already exists
|
|
331
|
+
spec_path = pending_dir / f"{spec_id}.json"
|
|
332
|
+
if spec_path.exists():
|
|
333
|
+
emit_error(
|
|
334
|
+
f"Specification already exists: {spec_id}",
|
|
335
|
+
code="DUPLICATE_ENTRY",
|
|
336
|
+
error_type="conflict",
|
|
337
|
+
remediation="Use a different name or delete the existing specification",
|
|
338
|
+
details={"spec_id": spec_id, "path": str(spec_path)},
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Generate spec structure
|
|
342
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
343
|
+
hierarchy = get_template_structure(template, category)
|
|
344
|
+
|
|
345
|
+
# Fill in the title
|
|
346
|
+
hierarchy["spec-root"]["title"] = name
|
|
347
|
+
|
|
348
|
+
spec_data = {
|
|
349
|
+
"spec_id": spec_id,
|
|
350
|
+
"title": name,
|
|
351
|
+
"generated": now,
|
|
352
|
+
"last_updated": now,
|
|
353
|
+
"metadata": {
|
|
354
|
+
"description": "",
|
|
355
|
+
"mission": mission.strip(),
|
|
356
|
+
"objectives": [],
|
|
357
|
+
"complexity": "low", # Set explicitly via metadata, not template
|
|
358
|
+
"estimated_hours": sum(
|
|
359
|
+
node.get("metadata", {}).get("estimated_hours", 0)
|
|
360
|
+
for node in hierarchy.values()
|
|
361
|
+
if isinstance(node, dict)
|
|
362
|
+
),
|
|
363
|
+
"assumptions": [],
|
|
364
|
+
"status": "pending",
|
|
365
|
+
"owner": "",
|
|
366
|
+
"progress_percentage": 0,
|
|
367
|
+
"current_phase": None, # Empty template has no phases
|
|
368
|
+
"category": category,
|
|
369
|
+
"template": template,
|
|
370
|
+
},
|
|
371
|
+
"progress_percentage": 0,
|
|
372
|
+
"status": "pending",
|
|
373
|
+
"current_phase": None, # Empty template has no phases
|
|
374
|
+
"hierarchy": hierarchy,
|
|
375
|
+
"journal": [],
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# Write the spec file
|
|
379
|
+
with open(spec_path, "w") as f:
|
|
380
|
+
json.dump(spec_data, f, indent=2)
|
|
381
|
+
|
|
382
|
+
# Count tasks
|
|
383
|
+
task_count = sum(
|
|
384
|
+
1
|
|
385
|
+
for node in hierarchy.values()
|
|
386
|
+
if isinstance(node, dict) and node.get("type") in ("task", "subtask", "verify")
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
emit_success(
|
|
390
|
+
{
|
|
391
|
+
"spec_id": spec_id,
|
|
392
|
+
"spec_path": str(spec_path),
|
|
393
|
+
"template": template,
|
|
394
|
+
"category": category,
|
|
395
|
+
"name": name,
|
|
396
|
+
"structure": {
|
|
397
|
+
"phases": len(
|
|
398
|
+
[
|
|
399
|
+
n
|
|
400
|
+
for n in hierarchy.values()
|
|
401
|
+
if isinstance(n, dict) and n.get("type") == "phase"
|
|
402
|
+
]
|
|
403
|
+
),
|
|
404
|
+
"tasks": task_count,
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@specs.command("schema")
|
|
411
|
+
@click.pass_context
|
|
412
|
+
@cli_command("schema")
|
|
413
|
+
@handle_keyboard_interrupt()
|
|
414
|
+
@with_sync_timeout(MEDIUM_TIMEOUT, "Schema export timed out")
|
|
415
|
+
def schema_cmd(ctx: click.Context) -> None:
|
|
416
|
+
"""Export the SDD spec JSON schema.
|
|
417
|
+
|
|
418
|
+
Returns the complete JSON schema for SDD specification files,
|
|
419
|
+
useful for validation, IDE integration, and agent understanding.
|
|
420
|
+
"""
|
|
421
|
+
from foundry_mcp.schemas import get_spec_schema
|
|
422
|
+
|
|
423
|
+
schema, error = get_spec_schema()
|
|
424
|
+
|
|
425
|
+
if schema is None:
|
|
426
|
+
emit_error(
|
|
427
|
+
"Failed to load schema",
|
|
428
|
+
code="INTERNAL_ERROR",
|
|
429
|
+
error_type="internal",
|
|
430
|
+
remediation="This may indicate a corrupted installation. Try reinstalling the package.",
|
|
431
|
+
details={"error": error},
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
emit_success(
|
|
435
|
+
{
|
|
436
|
+
"schema": schema,
|
|
437
|
+
"version": "1.0.0",
|
|
438
|
+
"source": "bundled",
|
|
439
|
+
}
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@specs.command("find")
|
|
444
|
+
@click.option(
|
|
445
|
+
"--status",
|
|
446
|
+
"-s",
|
|
447
|
+
type=click.Choice(["active", "pending", "completed", "archived", "all"]),
|
|
448
|
+
default="all",
|
|
449
|
+
help="Filter by status folder.",
|
|
450
|
+
)
|
|
451
|
+
@click.pass_context
|
|
452
|
+
@cli_command("find")
|
|
453
|
+
@handle_keyboard_interrupt()
|
|
454
|
+
@with_sync_timeout(MEDIUM_TIMEOUT, "Spec discovery timed out")
|
|
455
|
+
def find_specs_cmd(ctx: click.Context, status: str) -> None:
|
|
456
|
+
"""Find all specifications with progress information.
|
|
457
|
+
|
|
458
|
+
Lists specs sorted by status (active first) and completion percentage.
|
|
459
|
+
|
|
460
|
+
Examples:
|
|
461
|
+
sdd specs find
|
|
462
|
+
sdd specs find --status active
|
|
463
|
+
sdd specs find
|
|
464
|
+
"""
|
|
465
|
+
cli_ctx = get_context(ctx)
|
|
466
|
+
specs_dir = cli_ctx.specs_dir
|
|
467
|
+
|
|
468
|
+
if specs_dir is None:
|
|
469
|
+
emit_error(
|
|
470
|
+
"No specs directory found",
|
|
471
|
+
code="VALIDATION_ERROR",
|
|
472
|
+
error_type="validation",
|
|
473
|
+
remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
|
|
474
|
+
details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
|
|
475
|
+
)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
# Use core function to list specs
|
|
479
|
+
status_filter = None if status == "all" else status
|
|
480
|
+
specs_list = core_list_specs(specs_dir, status=status_filter)
|
|
481
|
+
|
|
482
|
+
emit_success(
|
|
483
|
+
{
|
|
484
|
+
"count": len(specs_list),
|
|
485
|
+
"status_filter": status if status != "all" else None,
|
|
486
|
+
"specs": specs_list,
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@specs.command("list-phases")
|
|
492
|
+
@click.argument("spec_id")
|
|
493
|
+
@click.pass_context
|
|
494
|
+
@cli_command("list-phases")
|
|
495
|
+
@handle_keyboard_interrupt()
|
|
496
|
+
@with_sync_timeout(FAST_TIMEOUT, "List phases timed out")
|
|
497
|
+
def list_phases_cmd(ctx: click.Context, spec_id: str) -> None:
|
|
498
|
+
"""List all phases in a specification with progress.
|
|
499
|
+
|
|
500
|
+
SPEC_ID is the specification identifier.
|
|
501
|
+
|
|
502
|
+
Examples:
|
|
503
|
+
sdd specs list-phases my-spec
|
|
504
|
+
sdd list-phases my-spec
|
|
505
|
+
"""
|
|
506
|
+
cli_ctx = get_context(ctx)
|
|
507
|
+
specs_dir = cli_ctx.specs_dir
|
|
508
|
+
|
|
509
|
+
if specs_dir is None:
|
|
510
|
+
emit_error(
|
|
511
|
+
"No specs directory found",
|
|
512
|
+
code="VALIDATION_ERROR",
|
|
513
|
+
error_type="validation",
|
|
514
|
+
remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
|
|
515
|
+
details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
|
|
516
|
+
)
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
# Load spec
|
|
520
|
+
spec_data = load_spec(spec_id, specs_dir)
|
|
521
|
+
if spec_data is None:
|
|
522
|
+
emit_error(
|
|
523
|
+
f"Specification not found: {spec_id}",
|
|
524
|
+
code="SPEC_NOT_FOUND",
|
|
525
|
+
error_type="not_found",
|
|
526
|
+
remediation="Verify the spec ID exists using: sdd specs find",
|
|
527
|
+
details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
|
|
528
|
+
)
|
|
529
|
+
return
|
|
530
|
+
|
|
531
|
+
phases = core_list_phases(spec_data)
|
|
532
|
+
|
|
533
|
+
emit_success(
|
|
534
|
+
{
|
|
535
|
+
"spec_id": spec_id,
|
|
536
|
+
"phase_count": len(phases),
|
|
537
|
+
"phases": phases,
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@specs.command("query-tasks")
|
|
543
|
+
@click.argument("spec_id")
|
|
544
|
+
@click.option(
|
|
545
|
+
"--status",
|
|
546
|
+
"-s",
|
|
547
|
+
help="Filter by status (pending, in_progress, completed, blocked).",
|
|
548
|
+
)
|
|
549
|
+
@click.option("--parent", "-p", help="Filter by parent node ID (e.g., phase-1).")
|
|
550
|
+
@click.pass_context
|
|
551
|
+
@cli_command("query-tasks")
|
|
552
|
+
@handle_keyboard_interrupt()
|
|
553
|
+
@with_sync_timeout(MEDIUM_TIMEOUT, "Query tasks timed out")
|
|
554
|
+
def query_tasks_cmd(
|
|
555
|
+
ctx: click.Context,
|
|
556
|
+
spec_id: str,
|
|
557
|
+
status: Optional[str],
|
|
558
|
+
parent: Optional[str],
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Query tasks in a specification with filters.
|
|
561
|
+
|
|
562
|
+
SPEC_ID is the specification identifier.
|
|
563
|
+
|
|
564
|
+
Examples:
|
|
565
|
+
sdd specs query-tasks my-spec
|
|
566
|
+
sdd specs query-tasks my-spec --status pending
|
|
567
|
+
sdd specs query-tasks my-spec --parent phase-2
|
|
568
|
+
sdd query-tasks my-spec --status in_progress
|
|
569
|
+
"""
|
|
570
|
+
cli_ctx = get_context(ctx)
|
|
571
|
+
specs_dir = cli_ctx.specs_dir
|
|
572
|
+
|
|
573
|
+
if specs_dir is None:
|
|
574
|
+
emit_error(
|
|
575
|
+
"No specs directory found",
|
|
576
|
+
code="VALIDATION_ERROR",
|
|
577
|
+
error_type="validation",
|
|
578
|
+
remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
|
|
579
|
+
details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
|
|
580
|
+
)
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
# Load spec
|
|
584
|
+
spec_data = load_spec(spec_id, specs_dir)
|
|
585
|
+
if spec_data is None:
|
|
586
|
+
emit_error(
|
|
587
|
+
f"Specification not found: {spec_id}",
|
|
588
|
+
code="SPEC_NOT_FOUND",
|
|
589
|
+
error_type="not_found",
|
|
590
|
+
remediation="Verify the spec ID exists using: sdd specs find",
|
|
591
|
+
details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
|
|
592
|
+
)
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
hierarchy = spec_data.get("hierarchy", {})
|
|
596
|
+
tasks = []
|
|
597
|
+
|
|
598
|
+
for node_id, node in hierarchy.items():
|
|
599
|
+
node_type = node.get("type", "")
|
|
600
|
+
if node_type not in ("task", "subtask", "verify"):
|
|
601
|
+
continue
|
|
602
|
+
|
|
603
|
+
# Apply filters
|
|
604
|
+
if status and node.get("status") != status:
|
|
605
|
+
continue
|
|
606
|
+
if parent and node.get("parent") != parent:
|
|
607
|
+
continue
|
|
608
|
+
|
|
609
|
+
tasks.append(
|
|
610
|
+
{
|
|
611
|
+
"task_id": node_id,
|
|
612
|
+
"title": node.get("title", ""),
|
|
613
|
+
"type": node_type,
|
|
614
|
+
"status": node.get("status", "pending"),
|
|
615
|
+
"parent": node.get("parent"),
|
|
616
|
+
"children": node.get("children", []),
|
|
617
|
+
}
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
# Sort by task_id
|
|
621
|
+
tasks.sort(key=lambda t: t["task_id"])
|
|
622
|
+
|
|
623
|
+
emit_success(
|
|
624
|
+
{
|
|
625
|
+
"spec_id": spec_id,
|
|
626
|
+
"filters": {
|
|
627
|
+
"status": status,
|
|
628
|
+
"parent": parent,
|
|
629
|
+
},
|
|
630
|
+
"task_count": len(tasks),
|
|
631
|
+
"tasks": tasks,
|
|
632
|
+
}
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@specs.command("list-blockers")
|
|
637
|
+
@click.argument("spec_id")
|
|
638
|
+
@click.pass_context
|
|
639
|
+
@cli_command("list-blockers")
|
|
640
|
+
@handle_keyboard_interrupt()
|
|
641
|
+
@with_sync_timeout(FAST_TIMEOUT, "List blockers timed out")
|
|
642
|
+
def list_blockers_cmd(ctx: click.Context, spec_id: str) -> None:
|
|
643
|
+
"""List all blocked tasks in a specification.
|
|
644
|
+
|
|
645
|
+
SPEC_ID is the specification identifier.
|
|
646
|
+
|
|
647
|
+
Returns tasks with status='blocked' and their blocker information.
|
|
648
|
+
|
|
649
|
+
Examples:
|
|
650
|
+
sdd specs list-blockers my-spec
|
|
651
|
+
sdd list-blockers my-spec
|
|
652
|
+
"""
|
|
653
|
+
cli_ctx = get_context(ctx)
|
|
654
|
+
specs_dir = cli_ctx.specs_dir
|
|
655
|
+
|
|
656
|
+
if specs_dir is None:
|
|
657
|
+
emit_error(
|
|
658
|
+
"No specs directory found",
|
|
659
|
+
code="VALIDATION_ERROR",
|
|
660
|
+
error_type="validation",
|
|
661
|
+
remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
|
|
662
|
+
details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
|
|
663
|
+
)
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
# Load spec
|
|
667
|
+
spec_data = load_spec(spec_id, specs_dir)
|
|
668
|
+
if spec_data is None:
|
|
669
|
+
emit_error(
|
|
670
|
+
f"Specification not found: {spec_id}",
|
|
671
|
+
code="SPEC_NOT_FOUND",
|
|
672
|
+
error_type="not_found",
|
|
673
|
+
remediation="Verify the spec ID exists using: sdd specs find",
|
|
674
|
+
details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
|
|
675
|
+
)
|
|
676
|
+
return
|
|
677
|
+
|
|
678
|
+
blocked = list_blocked_tasks(spec_data)
|
|
679
|
+
|
|
680
|
+
emit_success(
|
|
681
|
+
{
|
|
682
|
+
"spec_id": spec_id,
|
|
683
|
+
"blocker_count": len(blocked),
|
|
684
|
+
"blocked_tasks": blocked,
|
|
685
|
+
}
|
|
686
|
+
)
|