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