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,745 @@
1
+ """Unified plan tooling with action routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import time
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from foundry_mcp.config import ServerConfig
15
+ from foundry_mcp.core.ai_consultation import (
16
+ ConsultationOrchestrator,
17
+ ConsultationRequest,
18
+ ConsultationResult,
19
+ ConsultationWorkflow,
20
+ ConsensusResult,
21
+ )
22
+ from foundry_mcp.core.naming import canonical_tool
23
+ from foundry_mcp.core.observability import get_metrics, mcp_tool
24
+ from foundry_mcp.core.providers import available_providers
25
+ from foundry_mcp.core.responses import (
26
+ ErrorCode,
27
+ ErrorType,
28
+ ai_no_provider_error,
29
+ error_response,
30
+ success_response,
31
+ )
32
+ from foundry_mcp.core.security import is_prompt_injection
33
+ from foundry_mcp.core.spec import find_specs_directory
34
+ from foundry_mcp.tools.unified.router import (
35
+ ActionDefinition,
36
+ ActionRouter,
37
+ ActionRouterError,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+ _metrics = get_metrics()
42
+
43
+ REVIEW_TYPES = ["quick", "full", "security", "feasibility"]
44
+ REVIEW_TYPE_TO_TEMPLATE = {
45
+ "full": "MARKDOWN_PLAN_REVIEW_FULL_V1",
46
+ "quick": "MARKDOWN_PLAN_REVIEW_QUICK_V1",
47
+ "security": "MARKDOWN_PLAN_REVIEW_SECURITY_V1",
48
+ "feasibility": "MARKDOWN_PLAN_REVIEW_FEASIBILITY_V1",
49
+ }
50
+
51
+
52
+ def _extract_plan_name(plan_path: str) -> str:
53
+ """Extract plan name from file path."""
54
+
55
+ return Path(plan_path).stem
56
+
57
+
58
+ def _parse_review_summary(content: str) -> dict:
59
+ """Parse review markdown to extract section counts."""
60
+
61
+ summary = {
62
+ "critical_blockers": 0,
63
+ "major_suggestions": 0,
64
+ "minor_suggestions": 0,
65
+ "questions": 0,
66
+ "praise": 0,
67
+ }
68
+
69
+ sections = {
70
+ "Critical Blockers": "critical_blockers",
71
+ "Major Suggestions": "major_suggestions",
72
+ "Minor Suggestions": "minor_suggestions",
73
+ "Questions": "questions",
74
+ "Praise": "praise",
75
+ }
76
+
77
+ for section_name, key in sections.items():
78
+ pattern = rf"##\s*{section_name}\s*\n(.*?)(?=\n##|\Z)"
79
+ match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
80
+ if not match:
81
+ continue
82
+ section_content = match.group(1)
83
+ items = re.findall(r"^\s*-\s+\*\*\[", section_content, re.MULTILINE)
84
+ if not items:
85
+ items = re.findall(r"^\s*-\s+\*\*", section_content, re.MULTILINE)
86
+ if "None identified" in section_content and len(items) <= 1:
87
+ summary[key] = 0
88
+ else:
89
+ summary[key] = len(items)
90
+
91
+ return summary
92
+
93
+
94
+ def _format_inline_summary(summary: dict) -> str:
95
+ """Format summary dict into inline human-readable string."""
96
+
97
+ parts = []
98
+ if summary["critical_blockers"]:
99
+ parts.append(f"{summary['critical_blockers']} critical blocker(s)")
100
+ if summary["major_suggestions"]:
101
+ parts.append(f"{summary['major_suggestions']} major suggestion(s)")
102
+ if summary["minor_suggestions"]:
103
+ parts.append(f"{summary['minor_suggestions']} minor suggestion(s)")
104
+ if summary["questions"]:
105
+ parts.append(f"{summary['questions']} question(s)")
106
+ if summary["praise"]:
107
+ parts.append(f"{summary['praise']} praise item(s)")
108
+
109
+ return ", ".join(parts) if parts else "No issues identified"
110
+
111
+
112
+ def _get_llm_status() -> dict:
113
+ """Return current provider availability."""
114
+
115
+ providers = available_providers()
116
+ return {"available": bool(providers), "providers": providers}
117
+
118
+
119
+ PLAN_TEMPLATES = {
120
+ "simple": """# {name}
121
+
122
+ ## Objective
123
+
124
+ [Describe the primary goal of this plan]
125
+
126
+ ## Scope
127
+
128
+ [What is included/excluded from this plan]
129
+
130
+ ## Tasks
131
+
132
+ 1. [Task 1]
133
+ 2. [Task 2]
134
+ 3. [Task 3]
135
+
136
+ ## Success Criteria
137
+
138
+ - [ ] [Criterion 1]
139
+ - [ ] [Criterion 2]
140
+ """,
141
+ "detailed": """# {name}
142
+
143
+ ## Objective
144
+
145
+ [Describe the primary goal of this plan]
146
+
147
+ ## Scope
148
+
149
+ ### In Scope
150
+ - [Item 1]
151
+ - [Item 2]
152
+
153
+ ### Out of Scope
154
+ - [Item 1]
155
+
156
+ ## Phases
157
+
158
+ ### Phase 1: [Phase Name]
159
+
160
+ **Purpose**: [Why this phase exists]
161
+
162
+ **Tasks**:
163
+ 1. [Task 1]
164
+ 2. [Task 2]
165
+
166
+ **Verification**: [How to verify phase completion]
167
+
168
+ ### Phase 2: [Phase Name]
169
+
170
+ **Purpose**: [Why this phase exists]
171
+
172
+ **Tasks**:
173
+ 1. [Task 1]
174
+ 2. [Task 2]
175
+
176
+ **Verification**: [How to verify phase completion]
177
+
178
+ ## Risks and Mitigations
179
+
180
+ | Risk | Impact | Mitigation |
181
+ |------|--------|------------|
182
+ | [Risk 1] | [High/Medium/Low] | [Mitigation strategy] |
183
+
184
+ ## Success Criteria
185
+
186
+ - [ ] [Criterion 1]
187
+ - [ ] [Criterion 2]
188
+ - [ ] [Criterion 3]
189
+ """,
190
+ }
191
+
192
+
193
+ def _slugify(name: str) -> str:
194
+ """Convert a name to a slug."""
195
+
196
+ slug = name.lower().strip()
197
+ slug = re.sub(r"[^\w\s-]", "", slug)
198
+ return re.sub(r"[-\s]+", "-", slug)
199
+
200
+
201
+ def perform_plan_review(
202
+ *,
203
+ plan_path: str,
204
+ review_type: str = "full",
205
+ ai_provider: Optional[str] = None,
206
+ ai_timeout: float = 120.0,
207
+ consultation_cache: bool = True,
208
+ dry_run: bool = False,
209
+ ) -> dict:
210
+ """Execute the plan review workflow and return serialized response."""
211
+
212
+ start_time = time.perf_counter()
213
+
214
+ if review_type not in REVIEW_TYPES:
215
+ return asdict(
216
+ error_response(
217
+ f"Invalid review_type: {review_type}. Must be one of: {', '.join(REVIEW_TYPES)}",
218
+ error_code=ErrorCode.VALIDATION_ERROR,
219
+ error_type=ErrorType.VALIDATION,
220
+ remediation=f"Use one of: {', '.join(REVIEW_TYPES)}",
221
+ details={"review_type": review_type, "allowed": REVIEW_TYPES},
222
+ )
223
+ )
224
+
225
+ for field_name, field_value in (
226
+ ("plan_path", plan_path),
227
+ ("ai_provider", ai_provider),
228
+ ):
229
+ if field_value and is_prompt_injection(field_value):
230
+ _metrics.counter(
231
+ "plan_review.security_blocked",
232
+ labels={"tool": "plan-review", "reason": "prompt_injection"},
233
+ )
234
+ return asdict(
235
+ error_response(
236
+ f"Input validation failed for {field_name}",
237
+ error_code=ErrorCode.VALIDATION_ERROR,
238
+ error_type=ErrorType.VALIDATION,
239
+ remediation="Remove special characters or instruction-like patterns from input.",
240
+ )
241
+ )
242
+
243
+ llm_status = _get_llm_status()
244
+
245
+ plan_file = Path(plan_path)
246
+ if not plan_file.is_absolute():
247
+ plan_file = Path.cwd() / plan_file
248
+
249
+ if not plan_file.exists():
250
+ _metrics.counter(
251
+ "plan_review.errors",
252
+ labels={"tool": "plan-review", "error_type": "not_found"},
253
+ )
254
+ return asdict(
255
+ error_response(
256
+ f"Plan file not found: {plan_path}",
257
+ error_code=ErrorCode.NOT_FOUND,
258
+ error_type=ErrorType.NOT_FOUND,
259
+ remediation="Ensure the markdown plan exists at the specified path",
260
+ details={"plan_path": plan_path},
261
+ )
262
+ )
263
+
264
+ try:
265
+ plan_content = plan_file.read_text(encoding="utf-8")
266
+ except Exception as exc: # pragma: no cover - filesystem errors
267
+ _metrics.counter(
268
+ "plan_review.errors",
269
+ labels={"tool": "plan-review", "error_type": "read_error"},
270
+ )
271
+ return asdict(
272
+ error_response(
273
+ f"Failed to read plan file: {exc}",
274
+ error_code=ErrorCode.INTERNAL_ERROR,
275
+ error_type=ErrorType.INTERNAL,
276
+ remediation="Check file permissions and encoding",
277
+ details={"plan_path": str(plan_file)},
278
+ )
279
+ )
280
+
281
+ if not plan_content.strip():
282
+ _metrics.counter(
283
+ "plan_review.errors",
284
+ labels={"tool": "plan-review", "error_type": "empty_plan"},
285
+ )
286
+ return asdict(
287
+ error_response(
288
+ "Plan file is empty",
289
+ error_code=ErrorCode.VALIDATION_ERROR,
290
+ error_type=ErrorType.VALIDATION,
291
+ remediation="Add content to the markdown plan before reviewing",
292
+ details={"plan_path": str(plan_file)},
293
+ )
294
+ )
295
+
296
+ plan_name = _extract_plan_name(plan_file.name)
297
+
298
+ if dry_run:
299
+ return asdict(
300
+ success_response(
301
+ data={
302
+ "plan_path": str(plan_file),
303
+ "plan_name": plan_name,
304
+ "review_type": review_type,
305
+ "dry_run": True,
306
+ "llm_status": llm_status,
307
+ "message": "Dry run - review skipped",
308
+ },
309
+ telemetry={
310
+ "duration_ms": round((time.perf_counter() - start_time) * 1000, 2)
311
+ },
312
+ )
313
+ )
314
+
315
+ if not llm_status["available"]:
316
+ return asdict(
317
+ ai_no_provider_error(
318
+ "No AI provider available for plan review",
319
+ required_providers=["gemini", "codex", "cursor-agent"],
320
+ )
321
+ )
322
+
323
+ template_id = REVIEW_TYPE_TO_TEMPLATE[review_type]
324
+
325
+ try:
326
+ orchestrator = ConsultationOrchestrator()
327
+ request = ConsultationRequest(
328
+ workflow=ConsultationWorkflow.MARKDOWN_PLAN_REVIEW,
329
+ prompt_id=template_id,
330
+ context={
331
+ "plan_content": plan_content,
332
+ "plan_name": plan_name,
333
+ "plan_path": str(plan_file),
334
+ },
335
+ provider_id=ai_provider,
336
+ timeout=ai_timeout,
337
+ )
338
+ result = orchestrator.consult(request, use_cache=consultation_cache)
339
+
340
+ consensus_info: Optional[dict] = None
341
+ provider_used: Optional[str] = None
342
+
343
+ if isinstance(result, ConsultationResult):
344
+ if not result.success:
345
+ return asdict(
346
+ error_response(
347
+ f"AI consultation failed: {result.error}",
348
+ error_code=ErrorCode.AI_PROVIDER_ERROR,
349
+ error_type=ErrorType.AI_PROVIDER,
350
+ remediation="Check AI provider configuration or try again later",
351
+ )
352
+ )
353
+ review_content = result.content
354
+ provider_used = result.provider_id
355
+ elif isinstance(result, ConsensusResult):
356
+ if not result.success:
357
+ return asdict(
358
+ error_response(
359
+ "AI consultation failed - no successful responses",
360
+ error_code=ErrorCode.AI_PROVIDER_ERROR,
361
+ error_type=ErrorType.AI_PROVIDER,
362
+ remediation="Check AI provider configuration or try again later",
363
+ )
364
+ )
365
+ review_content = result.primary_content
366
+ providers_consulted = [r.provider_id for r in result.responses]
367
+ provider_used = providers_consulted[0] if providers_consulted else "unknown"
368
+ consensus_info = {
369
+ "providers_consulted": providers_consulted,
370
+ "successful": result.agreement.successful_providers
371
+ if result.agreement
372
+ else 0,
373
+ "failed": result.agreement.failed_providers if result.agreement else 0,
374
+ }
375
+ else: # pragma: no cover - defensive branch
376
+ logger.error("Unknown consultation result type: %s", type(result))
377
+ return asdict(
378
+ error_response(
379
+ "Unsupported consultation result",
380
+ error_code=ErrorCode.AI_PROVIDER_ERROR,
381
+ error_type=ErrorType.AI_PROVIDER,
382
+ )
383
+ )
384
+ except Exception as exc: # pragma: no cover - orchestration errors
385
+ _metrics.counter(
386
+ "plan_review.errors",
387
+ labels={"tool": "plan-review", "error_type": "consultation_error"},
388
+ )
389
+ return asdict(
390
+ error_response(
391
+ f"AI consultation failed: {exc}",
392
+ error_code=ErrorCode.AI_PROVIDER_ERROR,
393
+ error_type=ErrorType.AI_PROVIDER,
394
+ remediation="Check AI provider configuration or try again later",
395
+ )
396
+ )
397
+
398
+ summary = _parse_review_summary(review_content)
399
+ inline_summary = _format_inline_summary(summary)
400
+
401
+ specs_dir = find_specs_directory()
402
+ if specs_dir is None:
403
+ return asdict(
404
+ error_response(
405
+ "No specs directory found for storing plan review",
406
+ error_code=ErrorCode.NOT_FOUND,
407
+ error_type=ErrorType.NOT_FOUND,
408
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
409
+ )
410
+ )
411
+
412
+ plan_reviews_dir = specs_dir / ".plan-reviews"
413
+ try:
414
+ plan_reviews_dir.mkdir(parents=True, exist_ok=True)
415
+ review_file = plan_reviews_dir / f"{plan_name}-{review_type}.md"
416
+ review_file.write_text(review_content, encoding="utf-8")
417
+ except Exception as exc: # pragma: no cover - filesystem errors
418
+ _metrics.counter(
419
+ "plan_review.errors",
420
+ labels={"tool": "plan-review", "error_type": "write_error"},
421
+ )
422
+ return asdict(
423
+ error_response(
424
+ f"Failed to write review file: {exc}",
425
+ error_code=ErrorCode.INTERNAL_ERROR,
426
+ error_type=ErrorType.INTERNAL,
427
+ remediation="Check write permissions for specs/.plan-reviews/ directory",
428
+ )
429
+ )
430
+
431
+ duration_ms = (time.perf_counter() - start_time) * 1000
432
+ _metrics.counter(
433
+ "plan_review.completed",
434
+ labels={"tool": "plan-review", "review_type": review_type},
435
+ )
436
+
437
+ response_data = {
438
+ "plan_path": str(plan_file),
439
+ "plan_name": plan_name,
440
+ "review_type": review_type,
441
+ "review_path": str(review_file),
442
+ "summary": summary,
443
+ "inline_summary": inline_summary,
444
+ "llm_status": llm_status,
445
+ "provider_used": provider_used,
446
+ }
447
+ if consensus_info:
448
+ response_data["consensus"] = consensus_info
449
+
450
+ return asdict(
451
+ success_response(
452
+ data=response_data,
453
+ telemetry={"duration_ms": round(duration_ms, 2)},
454
+ )
455
+ )
456
+
457
+
458
+ def perform_plan_create(name: str, template: str = "detailed") -> dict:
459
+ """Create a markdown implementation plan using the requested template."""
460
+
461
+ start_time = time.perf_counter()
462
+
463
+ if template not in PLAN_TEMPLATES:
464
+ return asdict(
465
+ error_response(
466
+ f"Invalid template: {template}. Must be one of: simple, detailed",
467
+ error_code=ErrorCode.VALIDATION_ERROR,
468
+ error_type=ErrorType.VALIDATION,
469
+ remediation="Use 'simple' or 'detailed' template",
470
+ details={
471
+ "template": template,
472
+ "allowed": sorted(PLAN_TEMPLATES.keys()),
473
+ },
474
+ )
475
+ )
476
+
477
+ if is_prompt_injection(name):
478
+ _metrics.counter(
479
+ "plan_create.security_blocked",
480
+ labels={"tool": "plan-create", "reason": "prompt_injection"},
481
+ )
482
+ return asdict(
483
+ error_response(
484
+ "Input validation failed for name",
485
+ error_code=ErrorCode.VALIDATION_ERROR,
486
+ error_type=ErrorType.VALIDATION,
487
+ remediation="Remove special characters or instruction-like patterns from input.",
488
+ )
489
+ )
490
+
491
+ specs_dir = find_specs_directory()
492
+ if specs_dir is None:
493
+ return asdict(
494
+ error_response(
495
+ "No specs directory found",
496
+ error_code=ErrorCode.NOT_FOUND,
497
+ error_type=ErrorType.NOT_FOUND,
498
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
499
+ )
500
+ )
501
+
502
+ plans_dir = specs_dir / ".plans"
503
+ try:
504
+ plans_dir.mkdir(parents=True, exist_ok=True)
505
+ except Exception as exc:
506
+ return asdict(
507
+ error_response(
508
+ f"Failed to create plans directory: {exc}",
509
+ error_code=ErrorCode.INTERNAL_ERROR,
510
+ error_type=ErrorType.INTERNAL,
511
+ remediation="Check write permissions for specs/.plans/ directory",
512
+ )
513
+ )
514
+
515
+ plan_slug = _slugify(name)
516
+ plan_file = plans_dir / f"{plan_slug}.md"
517
+
518
+ if plan_file.exists():
519
+ return asdict(
520
+ error_response(
521
+ f"Plan already exists: {plan_file}",
522
+ error_code=ErrorCode.DUPLICATE_ENTRY,
523
+ error_type=ErrorType.CONFLICT,
524
+ remediation="Use a different name or delete the existing plan",
525
+ details={"plan_path": str(plan_file)},
526
+ )
527
+ )
528
+
529
+ plan_content = PLAN_TEMPLATES[template].format(name=name)
530
+ try:
531
+ plan_file.write_text(plan_content, encoding="utf-8")
532
+ except Exception as exc: # pragma: no cover - filesystem errors
533
+ return asdict(
534
+ error_response(
535
+ f"Failed to write plan file: {exc}",
536
+ error_code=ErrorCode.INTERNAL_ERROR,
537
+ error_type=ErrorType.INTERNAL,
538
+ remediation="Check write permissions for specs/.plans/ directory",
539
+ )
540
+ )
541
+
542
+ duration_ms = (time.perf_counter() - start_time) * 1000
543
+ _metrics.counter(
544
+ "plan_create.completed",
545
+ labels={"tool": "plan-create", "template": template},
546
+ )
547
+
548
+ return asdict(
549
+ success_response(
550
+ data={
551
+ "plan_name": name,
552
+ "plan_slug": plan_slug,
553
+ "plan_path": str(plan_file),
554
+ "template": template,
555
+ },
556
+ telemetry={"duration_ms": round(duration_ms, 2)},
557
+ )
558
+ )
559
+
560
+
561
+ def perform_plan_list() -> dict:
562
+ """List plans stored in specs/.plans and any associated reviews."""
563
+
564
+ start_time = time.perf_counter()
565
+
566
+ specs_dir = find_specs_directory()
567
+ if specs_dir is None:
568
+ return asdict(
569
+ error_response(
570
+ "No specs directory found",
571
+ error_code=ErrorCode.NOT_FOUND,
572
+ error_type=ErrorType.NOT_FOUND,
573
+ remediation="Create a specs/ directory with pending/active/completed/archived subdirectories",
574
+ )
575
+ )
576
+
577
+ plans_dir = specs_dir / ".plans"
578
+ if not plans_dir.exists():
579
+ return asdict(
580
+ success_response(
581
+ data={"plans": [], "count": 0, "plans_dir": str(plans_dir)},
582
+ telemetry={
583
+ "duration_ms": round((time.perf_counter() - start_time) * 1000, 2)
584
+ },
585
+ )
586
+ )
587
+
588
+ plans = []
589
+ for plan_file in sorted(plans_dir.glob("*.md")):
590
+ stat = plan_file.stat()
591
+ plans.append(
592
+ {
593
+ "name": plan_file.stem,
594
+ "path": str(plan_file),
595
+ "size_bytes": stat.st_size,
596
+ "modified": stat.st_mtime,
597
+ }
598
+ )
599
+
600
+ reviews_dir = specs_dir / ".plan-reviews"
601
+ for plan in plans:
602
+ plan_name = plan["name"]
603
+ if reviews_dir.exists():
604
+ review_files = list(reviews_dir.glob(f"{plan_name}-*.md"))
605
+ else:
606
+ review_files = []
607
+ plan["reviews"] = [rf.stem for rf in review_files]
608
+ plan["has_review"] = bool(review_files)
609
+
610
+ duration_ms = (time.perf_counter() - start_time) * 1000
611
+ _metrics.counter("plan_list.completed", labels={"tool": "plan-list"})
612
+
613
+ return asdict(
614
+ success_response(
615
+ data={"plans": plans, "count": len(plans), "plans_dir": str(plans_dir)},
616
+ telemetry={"duration_ms": round(duration_ms, 2)},
617
+ )
618
+ )
619
+
620
+
621
+ _ACTION_SUMMARY = {
622
+ "create": "Create markdown plan templates in specs/.plans",
623
+ "list": "Enumerate existing markdown plans and review coverage",
624
+ "review": "Run AI-assisted review workflows for markdown plans",
625
+ }
626
+
627
+
628
+ def _handle_plan_create(**payload: Any) -> dict:
629
+ name = payload.get("name")
630
+ template = payload.get("template", "detailed")
631
+ if not name:
632
+ return asdict(
633
+ error_response(
634
+ "Missing required parameter 'name' for plan.create",
635
+ error_code=ErrorCode.MISSING_REQUIRED,
636
+ error_type=ErrorType.VALIDATION,
637
+ remediation="Provide a plan name when action=create",
638
+ )
639
+ )
640
+ return perform_plan_create(name=name, template=template)
641
+
642
+
643
+ def _handle_plan_list(**_: Any) -> dict:
644
+ return perform_plan_list()
645
+
646
+
647
+ def _handle_plan_review(**payload: Any) -> dict:
648
+ plan_path = payload.get("plan_path")
649
+ if not plan_path:
650
+ return asdict(
651
+ error_response(
652
+ "Missing required parameter 'plan_path' for plan.review",
653
+ error_code=ErrorCode.MISSING_REQUIRED,
654
+ error_type=ErrorType.VALIDATION,
655
+ remediation="Provide a markdown plan path when action=review",
656
+ )
657
+ )
658
+ return perform_plan_review(
659
+ plan_path=plan_path,
660
+ review_type=payload.get("review_type", "full"),
661
+ ai_provider=payload.get("ai_provider"),
662
+ ai_timeout=payload.get("ai_timeout", 120.0),
663
+ consultation_cache=payload.get("consultation_cache", True),
664
+ dry_run=payload.get("dry_run", False),
665
+ )
666
+
667
+
668
+ _PLAN_ROUTER = ActionRouter(
669
+ tool_name="plan",
670
+ actions=[
671
+ ActionDefinition(
672
+ name="create",
673
+ handler=_handle_plan_create,
674
+ summary=_ACTION_SUMMARY["create"],
675
+ ),
676
+ ActionDefinition(
677
+ name="list", handler=_handle_plan_list, summary=_ACTION_SUMMARY["list"]
678
+ ),
679
+ ActionDefinition(
680
+ name="review",
681
+ handler=_handle_plan_review,
682
+ summary=_ACTION_SUMMARY["review"],
683
+ aliases=("plan-review",),
684
+ ),
685
+ ],
686
+ )
687
+
688
+
689
+ def _dispatch_plan_action(action: str, payload: Dict[str, Any]) -> dict:
690
+ try:
691
+ return _PLAN_ROUTER.dispatch(action=action, **payload)
692
+ except ActionRouterError as exc:
693
+ allowed = ", ".join(exc.allowed_actions)
694
+ return asdict(
695
+ error_response(
696
+ f"Unsupported plan action '{action}'. Allowed actions: {allowed}",
697
+ error_code=ErrorCode.VALIDATION_ERROR,
698
+ error_type=ErrorType.VALIDATION,
699
+ remediation=f"Use one of: {allowed}",
700
+ )
701
+ )
702
+
703
+
704
+ def register_unified_plan_tool(mcp: FastMCP, config: ServerConfig) -> None:
705
+ """Register the consolidated plan tool."""
706
+
707
+ @canonical_tool(
708
+ mcp,
709
+ canonical_name="plan",
710
+ )
711
+ @mcp_tool(tool_name="plan", emit_metrics=True, audit=True)
712
+ def plan(
713
+ action: str,
714
+ name: Optional[str] = None,
715
+ template: str = "detailed",
716
+ plan_path: Optional[str] = None,
717
+ review_type: str = "full",
718
+ ai_provider: Optional[str] = None,
719
+ ai_timeout: float = 120.0,
720
+ consultation_cache: bool = True,
721
+ dry_run: bool = False,
722
+ ) -> dict:
723
+ """Execute plan workflows via the action router."""
724
+
725
+ payload = {
726
+ "name": name,
727
+ "template": template,
728
+ "plan_path": plan_path,
729
+ "review_type": review_type,
730
+ "ai_provider": ai_provider,
731
+ "ai_timeout": ai_timeout,
732
+ "consultation_cache": consultation_cache,
733
+ "dry_run": dry_run,
734
+ }
735
+ return _dispatch_plan_action(action=action, payload=payload)
736
+
737
+ logger.debug("Registered unified plan tool")
738
+
739
+
740
+ __all__ = [
741
+ "register_unified_plan_tool",
742
+ "perform_plan_review",
743
+ "perform_plan_create",
744
+ "perform_plan_list",
745
+ ]