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,640 @@
1
+ """Plan review commands for SDD CLI.
2
+
3
+ Provides commands for reviewing markdown implementation plans
4
+ before converting them to formal JSON specifications.
5
+ """
6
+
7
+ import re
8
+ import time
9
+ from pathlib import Path
10
+ from typing import 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.resilience import (
17
+ SLOW_TIMEOUT,
18
+ MEDIUM_TIMEOUT,
19
+ with_sync_timeout,
20
+ handle_keyboard_interrupt,
21
+ )
22
+ from foundry_mcp.core.spec import find_specs_directory
23
+ from foundry_mcp.core.llm_config import get_consultation_config
24
+
25
+ logger = get_cli_logger()
26
+
27
+ # Default AI consultation timeout
28
+ DEFAULT_AI_TIMEOUT = 360.0
29
+
30
+ # Review types supported
31
+ REVIEW_TYPES = ["quick", "full", "security", "feasibility"]
32
+
33
+ # Map review types to MARKDOWN_PLAN_REVIEW templates
34
+ REVIEW_TYPE_TO_TEMPLATE = {
35
+ "full": "MARKDOWN_PLAN_REVIEW_FULL_V1",
36
+ "quick": "MARKDOWN_PLAN_REVIEW_QUICK_V1",
37
+ "security": "MARKDOWN_PLAN_REVIEW_SECURITY_V1",
38
+ "feasibility": "MARKDOWN_PLAN_REVIEW_FEASIBILITY_V1",
39
+ }
40
+
41
+
42
+ def _extract_plan_name(plan_path: str) -> str:
43
+ """Extract plan name from file path."""
44
+ path = Path(plan_path)
45
+ return path.stem
46
+
47
+
48
+ def _parse_review_summary(content: str) -> dict:
49
+ """
50
+ Parse review content to extract summary counts.
51
+
52
+ Returns dict with counts for each section.
53
+ """
54
+ summary = {
55
+ "critical_blockers": 0,
56
+ "major_suggestions": 0,
57
+ "minor_suggestions": 0,
58
+ "questions": 0,
59
+ "praise": 0,
60
+ }
61
+
62
+ # Count bullet points in each section
63
+ sections = {
64
+ "Critical Blockers": "critical_blockers",
65
+ "Major Suggestions": "major_suggestions",
66
+ "Minor Suggestions": "minor_suggestions",
67
+ "Questions": "questions",
68
+ "Praise": "praise",
69
+ }
70
+
71
+ for section_name, key in sections.items():
72
+ # Find section and count top-level bullets
73
+ pattern = rf"##\s*{section_name}\s*\n(.*?)(?=\n##|\Z)"
74
+ match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
75
+ if match:
76
+ section_content = match.group(1)
77
+ # Count lines starting with "- **" (top-level items)
78
+ items = re.findall(r"^\s*-\s+\*\*\[", section_content, re.MULTILINE)
79
+ # If no items found with category tags, count plain bullets
80
+ if not items:
81
+ items = re.findall(r"^\s*-\s+\*\*", section_content, re.MULTILINE)
82
+ # Don't count "None identified" as an item
83
+ if "None identified" in section_content and len(items) <= 1:
84
+ summary[key] = 0
85
+ else:
86
+ summary[key] = len(items)
87
+
88
+ return summary
89
+
90
+
91
+ def _format_inline_summary(summary: dict) -> str:
92
+ """Format summary dict into inline text."""
93
+ parts = []
94
+ if summary["critical_blockers"]:
95
+ parts.append(f"{summary['critical_blockers']} critical blocker(s)")
96
+ if summary["major_suggestions"]:
97
+ parts.append(f"{summary['major_suggestions']} major suggestion(s)")
98
+ if summary["minor_suggestions"]:
99
+ parts.append(f"{summary['minor_suggestions']} minor suggestion(s)")
100
+ if summary["questions"]:
101
+ parts.append(f"{summary['questions']} question(s)")
102
+ if summary["praise"]:
103
+ parts.append(f"{summary['praise']} praise item(s)")
104
+
105
+ if not parts:
106
+ return "No issues identified"
107
+ return ", ".join(parts)
108
+
109
+
110
+ def _get_llm_status() -> dict:
111
+ """Get current LLM provider status."""
112
+ try:
113
+ from foundry_mcp.core.providers import available_providers
114
+
115
+ providers = available_providers()
116
+ return {
117
+ "available": len(providers) > 0,
118
+ "providers": providers,
119
+ }
120
+ except ImportError:
121
+ return {
122
+ "available": False,
123
+ "providers": [],
124
+ }
125
+
126
+
127
+ @click.group("plan")
128
+ def plan_group() -> None:
129
+ """Markdown plan review commands."""
130
+ pass
131
+
132
+
133
+ @plan_group.command("review")
134
+ @click.argument("plan_path")
135
+ @click.option(
136
+ "--type",
137
+ "review_type",
138
+ type=click.Choice(REVIEW_TYPES),
139
+ default=None,
140
+ help="Type of review to perform (defaults to config value, typically 'full').",
141
+ )
142
+ @click.option(
143
+ "--ai-provider",
144
+ help="Explicit AI provider selection (e.g., gemini, cursor-agent).",
145
+ )
146
+ @click.option(
147
+ "--ai-timeout",
148
+ type=float,
149
+ default=DEFAULT_AI_TIMEOUT,
150
+ help=f"AI consultation timeout in seconds (default: {DEFAULT_AI_TIMEOUT}).",
151
+ )
152
+ @click.option(
153
+ "--no-consultation-cache",
154
+ is_flag=True,
155
+ help="Bypass AI consultation cache (always query providers fresh).",
156
+ )
157
+ @click.option(
158
+ "--dry-run",
159
+ is_flag=True,
160
+ help="Show what would be reviewed without executing.",
161
+ )
162
+ @click.pass_context
163
+ @cli_command("review")
164
+ @handle_keyboard_interrupt()
165
+ @with_sync_timeout(SLOW_TIMEOUT, "Plan review timed out")
166
+ def plan_review_cmd(
167
+ ctx: click.Context,
168
+ plan_path: str,
169
+ review_type: Optional[str],
170
+ ai_provider: Optional[str],
171
+ ai_timeout: float,
172
+ no_consultation_cache: bool,
173
+ dry_run: bool,
174
+ ) -> None:
175
+ """Review a markdown implementation plan with AI feedback.
176
+
177
+ Analyzes markdown plans before they become formal JSON specifications.
178
+ Writes review output to specs/.plan-reviews/<plan-name>-<review-type>.md.
179
+
180
+ Examples:
181
+
182
+ sdd plan review ./PLAN.md
183
+
184
+ sdd plan review ./PLAN.md --type security
185
+
186
+ sdd plan review ./PLAN.md --ai-provider gemini
187
+ """
188
+ # Get default review_type from config if not provided
189
+ if review_type is None:
190
+ consultation_config = get_consultation_config()
191
+ workflow_config = consultation_config.get_workflow_config("markdown_plan_review")
192
+ review_type = workflow_config.default_review_type
193
+
194
+ start_time = time.perf_counter()
195
+
196
+ llm_status = _get_llm_status()
197
+
198
+ # Resolve plan path
199
+ plan_file = Path(plan_path)
200
+ if not plan_file.is_absolute():
201
+ plan_file = Path.cwd() / plan_file
202
+
203
+ # Check if plan file exists
204
+ if not plan_file.exists():
205
+ emit_error(
206
+ f"Plan file not found: {plan_path}",
207
+ code="PLAN_NOT_FOUND",
208
+ error_type="not_found",
209
+ remediation="Ensure the markdown plan exists at the specified path",
210
+ )
211
+ return
212
+
213
+ # Read plan content
214
+ try:
215
+ plan_content = plan_file.read_text(encoding="utf-8")
216
+ except Exception as e:
217
+ emit_error(
218
+ f"Failed to read plan file: {e}",
219
+ code="READ_ERROR",
220
+ error_type="internal",
221
+ remediation="Check file permissions and encoding",
222
+ )
223
+ return
224
+
225
+ # Check for empty file
226
+ if not plan_content.strip():
227
+ emit_error(
228
+ "Plan file is empty",
229
+ code="EMPTY_PLAN",
230
+ error_type="validation",
231
+ remediation="Add content to the markdown plan before reviewing",
232
+ )
233
+ return
234
+
235
+ plan_name = _extract_plan_name(plan_path)
236
+
237
+ # Dry run - just show what would happen
238
+ if dry_run:
239
+ duration_ms = (time.perf_counter() - start_time) * 1000
240
+ emit_success(
241
+ {
242
+ "plan_path": str(plan_file),
243
+ "plan_name": plan_name,
244
+ "review_type": review_type,
245
+ "dry_run": True,
246
+ "llm_status": llm_status,
247
+ "message": "Dry run - review skipped",
248
+ },
249
+ telemetry={"duration_ms": round(duration_ms, 2)},
250
+ )
251
+ return
252
+
253
+ # Check LLM availability
254
+ if not llm_status["available"]:
255
+ emit_error(
256
+ "No AI provider available for plan review",
257
+ code="AI_NO_PROVIDER",
258
+ error_type="ai_provider",
259
+ remediation="Configure an AI provider: set GEMINI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY",
260
+ details={"required_providers": ["gemini", "codex", "cursor-agent"]},
261
+ )
262
+ return
263
+
264
+ # Build consultation request
265
+ template_id = REVIEW_TYPE_TO_TEMPLATE[review_type]
266
+
267
+ try:
268
+ from foundry_mcp.core.ai_consultation import (
269
+ ConsultationOrchestrator,
270
+ ConsultationRequest,
271
+ ConsultationWorkflow,
272
+ ConsultationResult,
273
+ )
274
+
275
+ orchestrator = ConsultationOrchestrator()
276
+
277
+ request = ConsultationRequest(
278
+ workflow=ConsultationWorkflow.MARKDOWN_PLAN_REVIEW,
279
+ prompt_id=template_id,
280
+ context={
281
+ "plan_content": plan_content,
282
+ "plan_name": plan_name,
283
+ "plan_path": str(plan_file),
284
+ },
285
+ provider_id=ai_provider,
286
+ timeout=ai_timeout,
287
+ )
288
+
289
+ result = orchestrator.consult(
290
+ request,
291
+ use_cache=not no_consultation_cache,
292
+ )
293
+
294
+ # Handle ConsultationResult
295
+ if isinstance(result, ConsultationResult):
296
+ if not result.success:
297
+ emit_error(
298
+ f"AI consultation failed: {result.error}",
299
+ code="AI_PROVIDER_ERROR",
300
+ error_type="ai_provider",
301
+ remediation="Check AI provider configuration or try again later",
302
+ )
303
+ return
304
+
305
+ review_content = result.content
306
+ provider_used = result.provider_id
307
+ else:
308
+ # ConsensusResult
309
+ if not result.success:
310
+ emit_error(
311
+ "AI consultation failed - no successful responses",
312
+ code="AI_PROVIDER_ERROR",
313
+ error_type="ai_provider",
314
+ remediation="Check AI provider configuration or try again later",
315
+ )
316
+ return
317
+
318
+ review_content = result.primary_content
319
+ provider_used = (
320
+ result.responses[0].provider_id if result.responses else "unknown"
321
+ )
322
+
323
+ except ImportError:
324
+ emit_error(
325
+ "AI consultation module not available",
326
+ code="INTERNAL_ERROR",
327
+ error_type="internal",
328
+ remediation="Check installation of foundry-mcp",
329
+ )
330
+ return
331
+ except Exception as e:
332
+ emit_error(
333
+ f"AI consultation failed: {e}",
334
+ code="AI_PROVIDER_ERROR",
335
+ error_type="ai_provider",
336
+ remediation="Check AI provider configuration or try again later",
337
+ )
338
+ return
339
+
340
+ # Parse review summary
341
+ summary = _parse_review_summary(review_content)
342
+ inline_summary = _format_inline_summary(summary)
343
+
344
+ # Find specs directory and write review to specs/.plan-reviews/
345
+ specs_dir = find_specs_directory()
346
+ if specs_dir is None:
347
+ emit_error(
348
+ "No specs directory found for storing plan review",
349
+ code="SPECS_NOT_FOUND",
350
+ error_type="validation",
351
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
352
+ )
353
+ return
354
+
355
+ plan_reviews_dir = specs_dir / ".plan-reviews"
356
+ try:
357
+ plan_reviews_dir.mkdir(parents=True, exist_ok=True)
358
+ review_file = plan_reviews_dir / f"{plan_name}-{review_type}.md"
359
+ review_file.write_text(review_content, encoding="utf-8")
360
+ except Exception as e:
361
+ emit_error(
362
+ f"Failed to write review file: {e}",
363
+ code="WRITE_ERROR",
364
+ error_type="internal",
365
+ remediation="Check write permissions for specs/.plan-reviews/ directory",
366
+ )
367
+ return
368
+
369
+ duration_ms = (time.perf_counter() - start_time) * 1000
370
+
371
+ emit_success(
372
+ {
373
+ "plan_path": str(plan_file),
374
+ "plan_name": plan_name,
375
+ "review_type": review_type,
376
+ "review_path": str(review_file),
377
+ "summary": summary,
378
+ "inline_summary": inline_summary,
379
+ "llm_status": llm_status,
380
+ "provider_used": provider_used,
381
+ },
382
+ telemetry={"duration_ms": round(duration_ms, 2)},
383
+ )
384
+
385
+
386
+ # Plan templates
387
+ PLAN_TEMPLATES = {
388
+ "simple": """# {name}
389
+
390
+ ## Objective
391
+
392
+ [Describe the primary goal of this plan]
393
+
394
+ ## Scope
395
+
396
+ [What is included/excluded from this plan]
397
+
398
+ ## Tasks
399
+
400
+ 1. [Task 1]
401
+ 2. [Task 2]
402
+ 3. [Task 3]
403
+
404
+ ## Success Criteria
405
+
406
+ - [ ] [Criterion 1]
407
+ - [ ] [Criterion 2]
408
+ """,
409
+ "detailed": """# {name}
410
+
411
+ ## Objective
412
+
413
+ [Describe the primary goal of this plan]
414
+
415
+ ## Scope
416
+
417
+ ### In Scope
418
+ - [Item 1]
419
+ - [Item 2]
420
+
421
+ ### Out of Scope
422
+ - [Item 1]
423
+
424
+ ## Phases
425
+
426
+ ### Phase 1: [Phase Name]
427
+
428
+ **Purpose**: [Why this phase exists]
429
+
430
+ **Tasks**:
431
+ 1. [Task 1]
432
+ 2. [Task 2]
433
+
434
+ **Verification**: [How to verify phase completion]
435
+
436
+ ### Phase 2: [Phase Name]
437
+
438
+ **Purpose**: [Why this phase exists]
439
+
440
+ **Tasks**:
441
+ 1. [Task 1]
442
+ 2. [Task 2]
443
+
444
+ **Verification**: [How to verify phase completion]
445
+
446
+ ## Risks and Mitigations
447
+
448
+ | Risk | Impact | Mitigation |
449
+ |------|--------|------------|
450
+ | [Risk 1] | [High/Medium/Low] | [Mitigation strategy] |
451
+
452
+ ## Success Criteria
453
+
454
+ - [ ] [Criterion 1]
455
+ - [ ] [Criterion 2]
456
+ - [ ] [Criterion 3]
457
+ """,
458
+ }
459
+
460
+
461
+ def _slugify(name: str) -> str:
462
+ """Convert a name to a URL-friendly slug."""
463
+ slug = name.lower().strip()
464
+ slug = re.sub(r"[^\w\s-]", "", slug)
465
+ slug = re.sub(r"[-\s]+", "-", slug)
466
+ return slug
467
+
468
+
469
+ @plan_group.command("create")
470
+ @click.argument("name")
471
+ @click.option(
472
+ "--template",
473
+ type=click.Choice(["simple", "detailed"]),
474
+ default="detailed",
475
+ help="Plan template to use.",
476
+ )
477
+ @click.pass_context
478
+ @cli_command("create")
479
+ @handle_keyboard_interrupt()
480
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Plan creation timed out")
481
+ def plan_create_cmd(
482
+ ctx: click.Context,
483
+ name: str,
484
+ template: str,
485
+ ) -> None:
486
+ """Create a new markdown implementation plan.
487
+
488
+ Creates a plan file in specs/.plans/ with the specified template.
489
+
490
+ Examples:
491
+
492
+ sdd plan create "Add user authentication"
493
+
494
+ sdd plan create "Refactor database layer" --template simple
495
+ """
496
+ start_time = time.perf_counter()
497
+
498
+ # Find specs directory
499
+ specs_dir = find_specs_directory()
500
+ if specs_dir is None:
501
+ emit_error(
502
+ "No specs directory found",
503
+ code="SPECS_NOT_FOUND",
504
+ error_type="validation",
505
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
506
+ )
507
+ return
508
+
509
+ # Create .plans directory if needed
510
+ plans_dir = specs_dir / ".plans"
511
+ try:
512
+ plans_dir.mkdir(parents=True, exist_ok=True)
513
+ except Exception as e:
514
+ emit_error(
515
+ f"Failed to create plans directory: {e}",
516
+ code="WRITE_ERROR",
517
+ error_type="internal",
518
+ remediation="Check write permissions for specs/.plans/ directory",
519
+ )
520
+ return
521
+
522
+ # Generate plan filename
523
+ plan_slug = _slugify(name)
524
+ plan_file = plans_dir / f"{plan_slug}.md"
525
+
526
+ # Check if plan already exists
527
+ if plan_file.exists():
528
+ emit_error(
529
+ f"Plan already exists: {plan_file}",
530
+ code="DUPLICATE_ENTRY",
531
+ error_type="conflict",
532
+ remediation="Use a different name or delete the existing plan",
533
+ details={"plan_path": str(plan_file)},
534
+ )
535
+ return
536
+
537
+ # Generate plan content from template
538
+ plan_content = PLAN_TEMPLATES[template].format(name=name)
539
+
540
+ # Write plan file
541
+ try:
542
+ plan_file.write_text(plan_content, encoding="utf-8")
543
+ except Exception as e:
544
+ emit_error(
545
+ f"Failed to write plan file: {e}",
546
+ code="WRITE_ERROR",
547
+ error_type="internal",
548
+ remediation="Check write permissions for specs/.plans/ directory",
549
+ )
550
+ return
551
+
552
+ duration_ms = (time.perf_counter() - start_time) * 1000
553
+
554
+ emit_success(
555
+ {
556
+ "plan_name": name,
557
+ "plan_slug": plan_slug,
558
+ "plan_path": str(plan_file),
559
+ "template": template,
560
+ },
561
+ telemetry={"duration_ms": round(duration_ms, 2)},
562
+ )
563
+
564
+
565
+ @plan_group.command("list")
566
+ @click.pass_context
567
+ @cli_command("list")
568
+ @handle_keyboard_interrupt()
569
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Plan listing timed out")
570
+ def plan_list_cmd(ctx: click.Context) -> None:
571
+ """List all markdown implementation plans.
572
+
573
+ Lists plans from specs/.plans/ directory.
574
+
575
+ Examples:
576
+
577
+ sdd plan list
578
+ """
579
+ start_time = time.perf_counter()
580
+
581
+ # Find specs directory
582
+ specs_dir = find_specs_directory()
583
+ if specs_dir is None:
584
+ emit_error(
585
+ "No specs directory found",
586
+ code="SPECS_NOT_FOUND",
587
+ error_type="validation",
588
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
589
+ )
590
+ return
591
+
592
+ plans_dir = specs_dir / ".plans"
593
+
594
+ # Check if plans directory exists
595
+ if not plans_dir.exists():
596
+ emit_success(
597
+ {
598
+ "plans": [],
599
+ "count": 0,
600
+ "plans_dir": str(plans_dir),
601
+ },
602
+ telemetry={
603
+ "duration_ms": round((time.perf_counter() - start_time) * 1000, 2)
604
+ },
605
+ )
606
+ return
607
+
608
+ # List all markdown files in plans directory
609
+ plans = []
610
+ for plan_file in sorted(plans_dir.glob("*.md")):
611
+ stat = plan_file.stat()
612
+ plans.append(
613
+ {
614
+ "name": plan_file.stem,
615
+ "path": str(plan_file),
616
+ "size_bytes": stat.st_size,
617
+ "modified": stat.st_mtime,
618
+ }
619
+ )
620
+
621
+ # Check for reviews
622
+ reviews_dir = specs_dir / ".plan-reviews"
623
+ for plan in plans:
624
+ plan_name = plan["name"]
625
+ review_files = (
626
+ list(reviews_dir.glob(f"{plan_name}-*.md")) if reviews_dir.exists() else []
627
+ )
628
+ plan["reviews"] = [rf.stem for rf in review_files]
629
+ plan["has_review"] = len(review_files) > 0
630
+
631
+ duration_ms = (time.perf_counter() - start_time) * 1000
632
+
633
+ emit_success(
634
+ {
635
+ "plans": plans,
636
+ "count": len(plans),
637
+ "plans_dir": str(plans_dir),
638
+ },
639
+ telemetry={"duration_ms": round(duration_ms, 2)},
640
+ )