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.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -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 +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -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 +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -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/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -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 +146 -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 +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -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 +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. 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
+ )