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,856 @@
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
+ TEMPLATES = ("simple", "medium", "complex", "security")
31
+ CATEGORIES = ("investigation", "implementation", "refactoring", "decision", "research")
32
+
33
+
34
+ def generate_spec_id(name: str) -> str:
35
+ """Generate a spec ID from a name.
36
+
37
+ Args:
38
+ name: Human-readable spec name.
39
+
40
+ Returns:
41
+ URL-safe spec ID with date suffix.
42
+ """
43
+ # Normalize: lowercase, replace spaces/special chars with hyphens
44
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
45
+ # Add date suffix
46
+ date_suffix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
47
+ # Add sequence number (001 for new specs)
48
+ return f"{slug}-{date_suffix}-001"
49
+
50
+
51
+ def get_template_structure(template: str, category: str) -> Dict[str, Any]:
52
+ """Get the hierarchical structure for a spec template.
53
+
54
+ Args:
55
+ template: Template type (simple, medium, complex, security).
56
+ category: Default task category.
57
+
58
+ Returns:
59
+ Hierarchy dict for the spec.
60
+ """
61
+ base_hierarchy = {
62
+ "spec-root": {
63
+ "type": "spec",
64
+ "title": "", # Filled in later
65
+ "status": "pending",
66
+ "parent": None,
67
+ "children": ["phase-1"],
68
+ "total_tasks": 0,
69
+ "completed_tasks": 0,
70
+ "metadata": {
71
+ "purpose": "",
72
+ "category": category,
73
+ },
74
+ "dependencies": {
75
+ "blocks": [],
76
+ "blocked_by": [],
77
+ "depends": [],
78
+ },
79
+ },
80
+ "phase-1": {
81
+ "type": "phase",
82
+ "title": "Planning & Discovery",
83
+ "status": "pending",
84
+ "parent": "spec-root",
85
+ "children": ["task-1-1"],
86
+ "total_tasks": 1,
87
+ "completed_tasks": 0,
88
+ "metadata": {
89
+ "purpose": "Initial planning and requirements gathering",
90
+ "estimated_hours": 2,
91
+ },
92
+ "dependencies": {
93
+ "blocks": [],
94
+ "blocked_by": [],
95
+ "depends": [],
96
+ },
97
+ },
98
+ "task-1-1": {
99
+ "type": "task",
100
+ "title": "Define requirements",
101
+ "status": "pending",
102
+ "parent": "phase-1",
103
+ "children": [],
104
+ "total_tasks": 1,
105
+ "completed_tasks": 0,
106
+ "metadata": {
107
+ "details": "Document the requirements and acceptance criteria",
108
+ "category": category,
109
+ "estimated_hours": 1,
110
+ },
111
+ "dependencies": {
112
+ "blocks": [],
113
+ "blocked_by": [],
114
+ "depends": [],
115
+ },
116
+ },
117
+ }
118
+
119
+ if template == "simple":
120
+ return base_hierarchy
121
+
122
+ # Medium template adds implementation phase
123
+ if template in ("medium", "complex", "security"):
124
+ base_hierarchy["spec-root"]["children"].append("phase-2")
125
+ base_hierarchy["phase-1"]["dependencies"]["blocks"].append("phase-2")
126
+ base_hierarchy["phase-2"] = {
127
+ "type": "phase",
128
+ "title": "Implementation",
129
+ "status": "pending",
130
+ "parent": "spec-root",
131
+ "children": ["task-2-1"],
132
+ "total_tasks": 1,
133
+ "completed_tasks": 0,
134
+ "metadata": {
135
+ "purpose": "Core implementation work",
136
+ "estimated_hours": 8,
137
+ },
138
+ "dependencies": {
139
+ "blocks": [],
140
+ "blocked_by": ["phase-1"],
141
+ "depends": [],
142
+ },
143
+ }
144
+ base_hierarchy["task-2-1"] = {
145
+ "type": "task",
146
+ "title": "Implement core functionality",
147
+ "status": "pending",
148
+ "parent": "phase-2",
149
+ "children": [],
150
+ "total_tasks": 1,
151
+ "completed_tasks": 0,
152
+ "metadata": {
153
+ "details": "Implement the main features",
154
+ "category": category,
155
+ "estimated_hours": 4,
156
+ },
157
+ "dependencies": {
158
+ "blocks": [],
159
+ "blocked_by": [],
160
+ "depends": [],
161
+ },
162
+ }
163
+ base_hierarchy["spec-root"]["total_tasks"] = 2
164
+ base_hierarchy["phase-1"]["total_tasks"] = 1
165
+
166
+ # Complex template adds verification phase
167
+ if template in ("complex", "security"):
168
+ base_hierarchy["spec-root"]["children"].append("phase-3")
169
+ base_hierarchy["phase-2"]["dependencies"]["blocks"].append("phase-3")
170
+ base_hierarchy["phase-3"] = {
171
+ "type": "phase",
172
+ "title": "Verification & Testing",
173
+ "status": "pending",
174
+ "parent": "spec-root",
175
+ "children": ["verify-3-1"],
176
+ "total_tasks": 1,
177
+ "completed_tasks": 0,
178
+ "metadata": {
179
+ "purpose": "Verify implementation meets requirements",
180
+ "estimated_hours": 4,
181
+ },
182
+ "dependencies": {
183
+ "blocks": [],
184
+ "blocked_by": ["phase-2"],
185
+ "depends": [],
186
+ },
187
+ }
188
+ base_hierarchy["verify-3-1"] = {
189
+ "type": "verify",
190
+ "title": "Run test suite",
191
+ "status": "pending",
192
+ "parent": "phase-3",
193
+ "children": [],
194
+ "total_tasks": 1,
195
+ "completed_tasks": 0,
196
+ "metadata": {
197
+ "verification_type": "auto",
198
+ "command": "pytest",
199
+ "expected": "All tests pass",
200
+ },
201
+ "dependencies": {
202
+ "blocks": [],
203
+ "blocked_by": [],
204
+ "depends": [],
205
+ },
206
+ }
207
+ base_hierarchy["spec-root"]["total_tasks"] = 3
208
+
209
+ # Security template adds security review phase
210
+ if template == "security":
211
+ base_hierarchy["spec-root"]["children"].append("phase-4")
212
+ base_hierarchy["phase-3"]["dependencies"]["blocks"].append("phase-4")
213
+ base_hierarchy["phase-4"] = {
214
+ "type": "phase",
215
+ "title": "Security Review",
216
+ "status": "pending",
217
+ "parent": "spec-root",
218
+ "children": ["task-4-1"],
219
+ "total_tasks": 1,
220
+ "completed_tasks": 0,
221
+ "metadata": {
222
+ "purpose": "Security audit and hardening",
223
+ "estimated_hours": 4,
224
+ },
225
+ "dependencies": {
226
+ "blocks": [],
227
+ "blocked_by": ["phase-3"],
228
+ "depends": [],
229
+ },
230
+ }
231
+ base_hierarchy["task-4-1"] = {
232
+ "type": "task",
233
+ "title": "Security audit",
234
+ "status": "pending",
235
+ "parent": "phase-4",
236
+ "children": [],
237
+ "total_tasks": 1,
238
+ "completed_tasks": 0,
239
+ "metadata": {
240
+ "details": "Review for security vulnerabilities",
241
+ "category": "investigation",
242
+ "estimated_hours": 2,
243
+ },
244
+ "dependencies": {
245
+ "blocks": [],
246
+ "blocked_by": [],
247
+ "depends": [],
248
+ },
249
+ }
250
+ base_hierarchy["spec-root"]["total_tasks"] = 4
251
+
252
+ return base_hierarchy
253
+
254
+
255
+ @click.group("specs")
256
+ def specs() -> None:
257
+ """Specification management commands."""
258
+ pass
259
+
260
+
261
+ # Template definitions for listing/showing
262
+ TEMPLATE_INFO = {
263
+ "simple": {
264
+ "name": "simple",
265
+ "description": "Minimal spec with single planning phase",
266
+ "phases": 1,
267
+ "tasks": 1,
268
+ "use_cases": ["Quick fixes", "Small features", "Simple investigations"],
269
+ },
270
+ "medium": {
271
+ "name": "medium",
272
+ "description": "Standard spec with planning and implementation phases",
273
+ "phases": 2,
274
+ "tasks": 2,
275
+ "use_cases": ["New features", "Moderate refactoring", "Standard development"],
276
+ },
277
+ "complex": {
278
+ "name": "complex",
279
+ "description": "Full spec with planning, implementation, and verification phases",
280
+ "phases": 3,
281
+ "tasks": 3,
282
+ "use_cases": ["Large features", "Major refactoring", "Critical systems"],
283
+ },
284
+ "security": {
285
+ "name": "security",
286
+ "description": "Complete spec with security review phase",
287
+ "phases": 4,
288
+ "tasks": 4,
289
+ "use_cases": ["Security-sensitive features", "Authentication", "Data handling"],
290
+ },
291
+ }
292
+
293
+
294
+ @specs.command("template")
295
+ @click.argument("action", type=click.Choice(["list", "show"]))
296
+ @click.argument("template_name", required=False)
297
+ @click.pass_context
298
+ @cli_command("template")
299
+ @handle_keyboard_interrupt()
300
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Template lookup timed out")
301
+ def template(
302
+ ctx: click.Context,
303
+ action: str,
304
+ template_name: Optional[str] = None,
305
+ ) -> None:
306
+ """List or show spec templates.
307
+
308
+ ACTION is either 'list' (show all templates) or 'show' (show template details).
309
+ TEMPLATE_NAME is required for 'show' action.
310
+ """
311
+ if action == "list":
312
+ templates = [
313
+ {
314
+ "name": info["name"],
315
+ "description": info["description"],
316
+ "phases": info["phases"],
317
+ "tasks": info["tasks"],
318
+ }
319
+ for info in TEMPLATE_INFO.values()
320
+ ]
321
+ emit_success(
322
+ {
323
+ "templates": templates,
324
+ "count": len(templates),
325
+ }
326
+ )
327
+
328
+ elif action == "show":
329
+ if not template_name:
330
+ emit_error(
331
+ "Template name required for 'show' action",
332
+ code="MISSING_REQUIRED",
333
+ error_type="validation",
334
+ remediation="Provide a template name: sdd specs template show <template_name>",
335
+ details={"required": "template_name"},
336
+ )
337
+
338
+ if template_name not in TEMPLATE_INFO:
339
+ emit_error(
340
+ f"Unknown template: {template_name}",
341
+ code="NOT_FOUND",
342
+ error_type="not_found",
343
+ remediation=f"Use one of the available templates: {', '.join(TEMPLATE_INFO.keys())}",
344
+ details={
345
+ "template": template_name,
346
+ "available": list(TEMPLATE_INFO.keys()),
347
+ },
348
+ )
349
+
350
+ info = TEMPLATE_INFO[template_name]
351
+ # Get the actual structure
352
+ structure = get_template_structure(template_name, "implementation")
353
+
354
+ emit_success(
355
+ {
356
+ "template": info,
357
+ "structure": {
358
+ "nodes": list(structure.keys()),
359
+ "hierarchy": {
360
+ node_id: {
361
+ "type": node["type"],
362
+ "title": node["title"],
363
+ "children": node.get("children", []),
364
+ }
365
+ for node_id, node in structure.items()
366
+ if isinstance(node, dict)
367
+ },
368
+ },
369
+ }
370
+ )
371
+
372
+
373
+ @specs.command("analyze")
374
+ @click.argument("directory", required=False)
375
+ @click.pass_context
376
+ @cli_command("analyze")
377
+ @handle_keyboard_interrupt()
378
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Spec analysis timed out")
379
+ def analyze(ctx: click.Context, directory: Optional[str] = None) -> None:
380
+ """Analyze specs directory structure and health.
381
+
382
+ DIRECTORY is the path to analyze (defaults to current directory).
383
+ """
384
+ cli_ctx = get_context(ctx)
385
+ target_dir = Path(directory) if directory else Path.cwd()
386
+
387
+ # Check for specs directory
388
+ specs_dir = cli_ctx.specs_dir
389
+ if specs_dir is None:
390
+ # Try to find specs in the target directory
391
+ for subdir in ("specs", "."):
392
+ candidate = target_dir / subdir
393
+ if candidate.is_dir():
394
+ for folder in ("pending", "active", "completed", "archived"):
395
+ if (candidate / folder).is_dir():
396
+ specs_dir = candidate
397
+ break
398
+ if specs_dir:
399
+ break
400
+
401
+ # Gather analysis data
402
+ analysis: Dict[str, Any] = {
403
+ "directory": str(target_dir.resolve()),
404
+ "has_specs": specs_dir is not None,
405
+ "specs_dir": str(specs_dir) if specs_dir else None,
406
+ }
407
+
408
+ if specs_dir and specs_dir.is_dir():
409
+ # Count specs by folder
410
+ folder_counts = {}
411
+ total_specs = 0
412
+ for folder in ("pending", "active", "completed", "archived"):
413
+ folder_path = specs_dir / folder
414
+ if folder_path.is_dir():
415
+ count = len(list(folder_path.glob("*.json")))
416
+ folder_counts[folder] = count
417
+ total_specs += count
418
+ else:
419
+ folder_counts[folder] = 0
420
+
421
+ analysis["spec_counts"] = folder_counts
422
+ analysis["total_specs"] = total_specs
423
+
424
+ # Check for documentation
425
+ docs_dir = specs_dir / ".human-readable"
426
+ analysis["documentation_available"] = docs_dir.is_dir() and any(
427
+ docs_dir.glob("*.md")
428
+ )
429
+
430
+ # Check for codebase docs
431
+ codebase_json = target_dir / "docs" / "codebase.json"
432
+ analysis["codebase_docs_available"] = codebase_json.is_file()
433
+
434
+ # Workspace health indicators
435
+ analysis["health"] = {
436
+ "has_active_specs": folder_counts.get("active", 0) > 0,
437
+ "has_pending_specs": folder_counts.get("pending", 0) > 0,
438
+ "completion_rate": (
439
+ round(folder_counts.get("completed", 0) / total_specs * 100, 1)
440
+ if total_specs > 0
441
+ else 0
442
+ ),
443
+ }
444
+ else:
445
+ analysis["spec_counts"] = None
446
+ analysis["total_specs"] = 0
447
+ analysis["documentation_available"] = False
448
+ analysis["codebase_docs_available"] = False
449
+ analysis["health"] = None
450
+
451
+ emit_success(analysis)
452
+
453
+
454
+ @specs.command("create")
455
+ @click.argument("name")
456
+ @click.option(
457
+ "--template",
458
+ type=click.Choice(TEMPLATES),
459
+ default="medium",
460
+ help="Spec template: simple, medium, complex, or security.",
461
+ )
462
+ @click.option(
463
+ "--category",
464
+ type=click.Choice(CATEGORIES),
465
+ default="implementation",
466
+ help="Default task category.",
467
+ )
468
+ @click.pass_context
469
+ @cli_command("create")
470
+ @handle_keyboard_interrupt()
471
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Spec creation timed out")
472
+ def create(
473
+ ctx: click.Context,
474
+ name: str,
475
+ template: str,
476
+ category: str,
477
+ ) -> None:
478
+ """Create a new specification.
479
+
480
+ NAME is the human-readable name for the specification.
481
+ """
482
+ cli_ctx = get_context(ctx)
483
+ specs_dir = cli_ctx.specs_dir
484
+
485
+ if specs_dir is None:
486
+ emit_error(
487
+ "No specs directory found",
488
+ code="VALIDATION_ERROR",
489
+ error_type="validation",
490
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
491
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
492
+ )
493
+
494
+ # Ensure pending directory exists
495
+ pending_dir = specs_dir / "pending"
496
+ pending_dir.mkdir(parents=True, exist_ok=True)
497
+
498
+ # Generate spec ID
499
+ spec_id = generate_spec_id(name)
500
+
501
+ # Check if spec already exists
502
+ spec_path = pending_dir / f"{spec_id}.json"
503
+ if spec_path.exists():
504
+ emit_error(
505
+ f"Specification already exists: {spec_id}",
506
+ code="DUPLICATE_ENTRY",
507
+ error_type="conflict",
508
+ remediation="Use a different name or delete the existing specification",
509
+ details={"spec_id": spec_id, "path": str(spec_path)},
510
+ )
511
+
512
+ # Generate spec structure
513
+ now = datetime.now(timezone.utc).isoformat()
514
+ hierarchy = get_template_structure(template, category)
515
+
516
+ # Fill in the title
517
+ hierarchy["spec-root"]["title"] = name
518
+
519
+ spec_data = {
520
+ "spec_id": spec_id,
521
+ "title": name,
522
+ "generated": now,
523
+ "last_updated": now,
524
+ "metadata": {
525
+ "description": "",
526
+ "objectives": [],
527
+ "complexity": "medium" if template in ("medium", "complex") else "low",
528
+ "estimated_hours": sum(
529
+ node.get("metadata", {}).get("estimated_hours", 0)
530
+ for node in hierarchy.values()
531
+ if isinstance(node, dict)
532
+ ),
533
+ "assumptions": [],
534
+ "status": "pending",
535
+ "owner": "",
536
+ "progress_percentage": 0,
537
+ "current_phase": "phase-1",
538
+ "category": category,
539
+ "template": template,
540
+ },
541
+ "progress_percentage": 0,
542
+ "status": "pending",
543
+ "current_phase": "phase-1",
544
+ "hierarchy": hierarchy,
545
+ "journal": [],
546
+ }
547
+
548
+ # Write the spec file
549
+ with open(spec_path, "w") as f:
550
+ json.dump(spec_data, f, indent=2)
551
+
552
+ # Count tasks
553
+ task_count = sum(
554
+ 1
555
+ for node in hierarchy.values()
556
+ if isinstance(node, dict) and node.get("type") in ("task", "subtask", "verify")
557
+ )
558
+
559
+ emit_success(
560
+ {
561
+ "spec_id": spec_id,
562
+ "spec_path": str(spec_path),
563
+ "template": template,
564
+ "category": category,
565
+ "name": name,
566
+ "structure": {
567
+ "phases": len(
568
+ [
569
+ n
570
+ for n in hierarchy.values()
571
+ if isinstance(n, dict) and n.get("type") == "phase"
572
+ ]
573
+ ),
574
+ "tasks": task_count,
575
+ },
576
+ }
577
+ )
578
+
579
+
580
+ @specs.command("schema")
581
+ @click.pass_context
582
+ @cli_command("schema")
583
+ @handle_keyboard_interrupt()
584
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Schema export timed out")
585
+ def schema_cmd(ctx: click.Context) -> None:
586
+ """Export the SDD spec JSON schema.
587
+
588
+ Returns the complete JSON schema for SDD specification files,
589
+ useful for validation, IDE integration, and agent understanding.
590
+ """
591
+ from foundry_mcp.schemas import get_spec_schema
592
+
593
+ schema, error = get_spec_schema()
594
+
595
+ if schema is None:
596
+ emit_error(
597
+ "Failed to load schema",
598
+ code="INTERNAL_ERROR",
599
+ error_type="internal",
600
+ remediation="This may indicate a corrupted installation. Try reinstalling the package.",
601
+ details={"error": error},
602
+ )
603
+
604
+ emit_success(
605
+ {
606
+ "schema": schema,
607
+ "version": "1.0.0",
608
+ "source": "bundled",
609
+ }
610
+ )
611
+
612
+
613
+ @specs.command("find")
614
+ @click.option(
615
+ "--status",
616
+ "-s",
617
+ type=click.Choice(["active", "pending", "completed", "archived", "all"]),
618
+ default="all",
619
+ help="Filter by status folder.",
620
+ )
621
+ @click.pass_context
622
+ @cli_command("find")
623
+ @handle_keyboard_interrupt()
624
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Spec discovery timed out")
625
+ def find_specs_cmd(ctx: click.Context, status: str) -> None:
626
+ """Find all specifications with progress information.
627
+
628
+ Lists specs sorted by status (active first) and completion percentage.
629
+
630
+ Examples:
631
+ sdd specs find
632
+ sdd specs find --status active
633
+ sdd specs find
634
+ """
635
+ cli_ctx = get_context(ctx)
636
+ specs_dir = cli_ctx.specs_dir
637
+
638
+ if specs_dir is None:
639
+ emit_error(
640
+ "No specs directory found",
641
+ code="VALIDATION_ERROR",
642
+ error_type="validation",
643
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
644
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
645
+ )
646
+ return
647
+
648
+ # Use core function to list specs
649
+ status_filter = None if status == "all" else status
650
+ specs_list = core_list_specs(specs_dir, status=status_filter)
651
+
652
+ emit_success(
653
+ {
654
+ "count": len(specs_list),
655
+ "status_filter": status if status != "all" else None,
656
+ "specs": specs_list,
657
+ }
658
+ )
659
+
660
+
661
+ @specs.command("list-phases")
662
+ @click.argument("spec_id")
663
+ @click.pass_context
664
+ @cli_command("list-phases")
665
+ @handle_keyboard_interrupt()
666
+ @with_sync_timeout(FAST_TIMEOUT, "List phases timed out")
667
+ def list_phases_cmd(ctx: click.Context, spec_id: str) -> None:
668
+ """List all phases in a specification with progress.
669
+
670
+ SPEC_ID is the specification identifier.
671
+
672
+ Examples:
673
+ sdd specs list-phases my-spec
674
+ sdd list-phases my-spec
675
+ """
676
+ cli_ctx = get_context(ctx)
677
+ specs_dir = cli_ctx.specs_dir
678
+
679
+ if specs_dir is None:
680
+ emit_error(
681
+ "No specs directory found",
682
+ code="VALIDATION_ERROR",
683
+ error_type="validation",
684
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
685
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
686
+ )
687
+ return
688
+
689
+ # Load spec
690
+ spec_data = load_spec(spec_id, specs_dir)
691
+ if spec_data is None:
692
+ emit_error(
693
+ f"Specification not found: {spec_id}",
694
+ code="SPEC_NOT_FOUND",
695
+ error_type="not_found",
696
+ remediation="Verify the spec ID exists using: sdd specs find",
697
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
698
+ )
699
+ return
700
+
701
+ phases = core_list_phases(spec_data)
702
+
703
+ emit_success(
704
+ {
705
+ "spec_id": spec_id,
706
+ "phase_count": len(phases),
707
+ "phases": phases,
708
+ }
709
+ )
710
+
711
+
712
+ @specs.command("query-tasks")
713
+ @click.argument("spec_id")
714
+ @click.option(
715
+ "--status",
716
+ "-s",
717
+ help="Filter by status (pending, in_progress, completed, blocked).",
718
+ )
719
+ @click.option("--parent", "-p", help="Filter by parent node ID (e.g., phase-1).")
720
+ @click.pass_context
721
+ @cli_command("query-tasks")
722
+ @handle_keyboard_interrupt()
723
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Query tasks timed out")
724
+ def query_tasks_cmd(
725
+ ctx: click.Context,
726
+ spec_id: str,
727
+ status: Optional[str],
728
+ parent: Optional[str],
729
+ ) -> None:
730
+ """Query tasks in a specification with filters.
731
+
732
+ SPEC_ID is the specification identifier.
733
+
734
+ Examples:
735
+ sdd specs query-tasks my-spec
736
+ sdd specs query-tasks my-spec --status pending
737
+ sdd specs query-tasks my-spec --parent phase-2
738
+ sdd query-tasks my-spec --status in_progress
739
+ """
740
+ cli_ctx = get_context(ctx)
741
+ specs_dir = cli_ctx.specs_dir
742
+
743
+ if specs_dir is None:
744
+ emit_error(
745
+ "No specs directory found",
746
+ code="VALIDATION_ERROR",
747
+ error_type="validation",
748
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
749
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
750
+ )
751
+ return
752
+
753
+ # Load spec
754
+ spec_data = load_spec(spec_id, specs_dir)
755
+ if spec_data is None:
756
+ emit_error(
757
+ f"Specification not found: {spec_id}",
758
+ code="SPEC_NOT_FOUND",
759
+ error_type="not_found",
760
+ remediation="Verify the spec ID exists using: sdd specs find",
761
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
762
+ )
763
+ return
764
+
765
+ hierarchy = spec_data.get("hierarchy", {})
766
+ tasks = []
767
+
768
+ for node_id, node in hierarchy.items():
769
+ node_type = node.get("type", "")
770
+ if node_type not in ("task", "subtask", "verify"):
771
+ continue
772
+
773
+ # Apply filters
774
+ if status and node.get("status") != status:
775
+ continue
776
+ if parent and node.get("parent") != parent:
777
+ continue
778
+
779
+ tasks.append(
780
+ {
781
+ "task_id": node_id,
782
+ "title": node.get("title", ""),
783
+ "type": node_type,
784
+ "status": node.get("status", "pending"),
785
+ "parent": node.get("parent"),
786
+ "children": node.get("children", []),
787
+ }
788
+ )
789
+
790
+ # Sort by task_id
791
+ tasks.sort(key=lambda t: t["task_id"])
792
+
793
+ emit_success(
794
+ {
795
+ "spec_id": spec_id,
796
+ "filters": {
797
+ "status": status,
798
+ "parent": parent,
799
+ },
800
+ "task_count": len(tasks),
801
+ "tasks": tasks,
802
+ }
803
+ )
804
+
805
+
806
+ @specs.command("list-blockers")
807
+ @click.argument("spec_id")
808
+ @click.pass_context
809
+ @cli_command("list-blockers")
810
+ @handle_keyboard_interrupt()
811
+ @with_sync_timeout(FAST_TIMEOUT, "List blockers timed out")
812
+ def list_blockers_cmd(ctx: click.Context, spec_id: str) -> None:
813
+ """List all blocked tasks in a specification.
814
+
815
+ SPEC_ID is the specification identifier.
816
+
817
+ Returns tasks with status='blocked' and their blocker information.
818
+
819
+ Examples:
820
+ sdd specs list-blockers my-spec
821
+ sdd list-blockers my-spec
822
+ """
823
+ cli_ctx = get_context(ctx)
824
+ specs_dir = cli_ctx.specs_dir
825
+
826
+ if specs_dir is None:
827
+ emit_error(
828
+ "No specs directory found",
829
+ code="VALIDATION_ERROR",
830
+ error_type="validation",
831
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
832
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
833
+ )
834
+ return
835
+
836
+ # Load spec
837
+ spec_data = load_spec(spec_id, specs_dir)
838
+ if spec_data is None:
839
+ emit_error(
840
+ f"Specification not found: {spec_id}",
841
+ code="SPEC_NOT_FOUND",
842
+ error_type="not_found",
843
+ remediation="Verify the spec ID exists using: sdd specs find",
844
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
845
+ )
846
+ return
847
+
848
+ blocked = list_blocked_tasks(spec_data)
849
+
850
+ emit_success(
851
+ {
852
+ "spec_id": spec_id,
853
+ "blocker_count": len(blocked),
854
+ "blocked_tasks": blocked,
855
+ }
856
+ )