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,824 @@
1
+ """Modification commands for SDD CLI.
2
+
3
+ Provides commands for modifying SDD specifications including:
4
+ - Applying bulk modifications from JSON files
5
+ - Adding/removing tasks
6
+ - Managing assumptions and revisions
7
+ - Updating frontmatter metadata
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from typing import Optional
13
+
14
+ import click
15
+
16
+ from foundry_mcp.cli.logging import cli_command, get_cli_logger
17
+ from foundry_mcp.cli.output import emit_error, emit_success
18
+ from foundry_mcp.cli.registry import get_context
19
+ from foundry_mcp.cli.resilience import (
20
+ MEDIUM_TIMEOUT,
21
+ with_sync_timeout,
22
+ handle_keyboard_interrupt,
23
+ )
24
+ from foundry_mcp.core.modifications import apply_modifications, load_modifications_file
25
+ from foundry_mcp.core.task import add_task, remove_task
26
+ from foundry_mcp.core.spec import (
27
+ add_assumption,
28
+ add_phase,
29
+ add_revision,
30
+ update_frontmatter,
31
+ )
32
+
33
+ logger = get_cli_logger()
34
+
35
+ # Default timeout for modification operations
36
+ MODIFY_TIMEOUT = 60
37
+
38
+
39
+ @click.group("modify")
40
+ def modify_group() -> None:
41
+ """Spec modification commands."""
42
+ pass
43
+
44
+
45
+ @modify_group.command("apply")
46
+ @click.argument("spec_id")
47
+ @click.argument("modifications_file", type=click.Path(exists=True))
48
+ @click.option(
49
+ "--dry-run",
50
+ is_flag=True,
51
+ help="Preview changes without applying.",
52
+ )
53
+ @click.option(
54
+ "--output",
55
+ "output_file",
56
+ type=click.Path(),
57
+ help="Output path for modified spec (default: overwrite original).",
58
+ )
59
+ @click.pass_context
60
+ @cli_command("apply")
61
+ @handle_keyboard_interrupt()
62
+ @with_sync_timeout(MODIFY_TIMEOUT, "Apply modifications timed out")
63
+ def modify_apply_cmd(
64
+ ctx: click.Context,
65
+ spec_id: str,
66
+ modifications_file: str,
67
+ dry_run: bool,
68
+ output_file: Optional[str],
69
+ ) -> None:
70
+ """Apply bulk modifications from a JSON file.
71
+
72
+ SPEC_ID is the specification identifier.
73
+ MODIFICATIONS_FILE is the path to the JSON modifications file.
74
+
75
+ The modifications file should contain structured changes like
76
+ task additions, updates, removals, and metadata modifications.
77
+ """
78
+ start_time = time.perf_counter()
79
+ cli_ctx = get_context(ctx)
80
+ specs_dir = cli_ctx.specs_dir
81
+
82
+ if specs_dir is None:
83
+ emit_error(
84
+ "No specs directory found",
85
+ code="VALIDATION_ERROR",
86
+ error_type="validation",
87
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
88
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
89
+ )
90
+ return
91
+
92
+ try:
93
+ # Load modifications from file
94
+ modifications = load_modifications_file(modifications_file)
95
+
96
+ # Apply modifications using native Python API
97
+ applied, skipped, changes = apply_modifications(
98
+ spec_id=spec_id,
99
+ modifications=modifications,
100
+ specs_dir=specs_dir,
101
+ dry_run=dry_run,
102
+ )
103
+
104
+ duration_ms = (time.perf_counter() - start_time) * 1000
105
+
106
+ emit_success(
107
+ {
108
+ "spec_id": spec_id,
109
+ "dry_run": dry_run,
110
+ "modifications_applied": applied,
111
+ "modifications_skipped": skipped,
112
+ "changes": changes,
113
+ "output_path": output_file if output_file else str(specs_dir),
114
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
115
+ }
116
+ )
117
+
118
+ except FileNotFoundError as e:
119
+ emit_error(
120
+ str(e),
121
+ code="FILE_NOT_FOUND",
122
+ error_type="validation",
123
+ remediation="Check that the modifications file and spec exist",
124
+ details={
125
+ "spec_id": spec_id,
126
+ "modifications_file": modifications_file,
127
+ },
128
+ )
129
+ except json.JSONDecodeError as e:
130
+ emit_error(
131
+ f"Invalid JSON in modifications file: {e}",
132
+ code="INVALID_JSON",
133
+ error_type="validation",
134
+ remediation="Check that the modifications file is valid JSON",
135
+ details={
136
+ "spec_id": spec_id,
137
+ "modifications_file": modifications_file,
138
+ },
139
+ )
140
+ except ValueError as e:
141
+ emit_error(
142
+ str(e),
143
+ code="APPLY_FAILED",
144
+ error_type="internal",
145
+ remediation="Verify spec exists and modifications are valid",
146
+ details={
147
+ "spec_id": spec_id,
148
+ "modifications_file": modifications_file,
149
+ },
150
+ )
151
+
152
+
153
+ # Phase subgroup
154
+ @modify_group.group("phase")
155
+ def modify_phase_group() -> None:
156
+ """Phase modification commands."""
157
+ pass
158
+
159
+
160
+ @modify_phase_group.command("add")
161
+ @click.argument("spec_id")
162
+ @click.option(
163
+ "--title",
164
+ required=True,
165
+ help="Phase title.",
166
+ )
167
+ @click.option(
168
+ "--description",
169
+ help="Phase description or scope.",
170
+ )
171
+ @click.option(
172
+ "--purpose",
173
+ help="Phase purpose metadata.",
174
+ )
175
+ @click.option(
176
+ "--estimated-hours",
177
+ "estimated_hours",
178
+ type=float,
179
+ help="Estimated hours for this phase.",
180
+ )
181
+ @click.option(
182
+ "--position",
183
+ type=int,
184
+ help="Insertion index under spec-root children (0-based).",
185
+ )
186
+ @click.option(
187
+ "--link-previous/--no-link-previous",
188
+ default=True,
189
+ show_default=True,
190
+ help="Automatically block on the previous phase when appending.",
191
+ )
192
+ @click.option(
193
+ "--dry-run",
194
+ is_flag=True,
195
+ help="Preview changes without applying.",
196
+ )
197
+ @click.pass_context
198
+ @cli_command("phase-add")
199
+ @handle_keyboard_interrupt()
200
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Add phase timed out")
201
+ def modify_phase_add_cmd(
202
+ ctx: click.Context,
203
+ spec_id: str,
204
+ title: str,
205
+ description: Optional[str],
206
+ purpose: Optional[str],
207
+ estimated_hours: Optional[float],
208
+ position: Optional[int],
209
+ link_previous: bool,
210
+ dry_run: bool,
211
+ ) -> None:
212
+ """Add a new phase to a specification."""
213
+ start_time = time.perf_counter()
214
+ cli_ctx = get_context(ctx)
215
+ specs_dir = cli_ctx.specs_dir
216
+
217
+ if specs_dir is None:
218
+ emit_error(
219
+ "No specs directory found",
220
+ code="VALIDATION_ERROR",
221
+ error_type="validation",
222
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
223
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
224
+ )
225
+ return
226
+
227
+ if dry_run:
228
+ duration_ms = (time.perf_counter() - start_time) * 1000
229
+ emit_success(
230
+ {
231
+ "spec_id": spec_id,
232
+ "title": title,
233
+ "dry_run": True,
234
+ "preview": {
235
+ "action": "add_phase",
236
+ "description": description,
237
+ "purpose": purpose,
238
+ "estimated_hours": estimated_hours,
239
+ "position": position,
240
+ "link_previous": link_previous,
241
+ },
242
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
243
+ }
244
+ )
245
+ return
246
+
247
+ result, error = add_phase(
248
+ spec_id=spec_id,
249
+ title=title,
250
+ description=description,
251
+ purpose=purpose,
252
+ estimated_hours=estimated_hours,
253
+ position=position,
254
+ link_previous=link_previous,
255
+ specs_dir=specs_dir,
256
+ )
257
+
258
+ duration_ms = (time.perf_counter() - start_time) * 1000
259
+
260
+ if error:
261
+ emit_error(
262
+ f"Add phase failed: {error}",
263
+ code="ADD_FAILED",
264
+ error_type="internal",
265
+ remediation="Check that the spec exists and parameters are valid",
266
+ details={
267
+ "spec_id": spec_id,
268
+ "title": title,
269
+ },
270
+ )
271
+ return
272
+
273
+ emit_success(
274
+ {
275
+ "spec_id": spec_id,
276
+ "dry_run": False,
277
+ **result,
278
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
279
+ }
280
+ )
281
+
282
+
283
+ # Task subgroup
284
+ @modify_group.group("task")
285
+ def modify_task_group() -> None:
286
+ """Task modification commands."""
287
+ pass
288
+
289
+
290
+ @modify_task_group.command("add")
291
+ @click.argument("spec_id")
292
+ @click.option(
293
+ "--parent",
294
+ required=True,
295
+ help="Parent node ID (e.g., phase-1, task-2-1).",
296
+ )
297
+ @click.option(
298
+ "--title",
299
+ required=True,
300
+ help="Task title.",
301
+ )
302
+ @click.option(
303
+ "--description",
304
+ help="Task description.",
305
+ )
306
+ @click.option(
307
+ "--type",
308
+ "task_type",
309
+ type=click.Choice(["task", "subtask", "verify"]),
310
+ default="task",
311
+ help="Task type.",
312
+ )
313
+ @click.option(
314
+ "--hours",
315
+ type=float,
316
+ help="Estimated hours.",
317
+ )
318
+ @click.option(
319
+ "--position",
320
+ type=int,
321
+ help="Position in parent's children list (0-based).",
322
+ )
323
+ @click.option(
324
+ "--dry-run",
325
+ is_flag=True,
326
+ help="Preview changes without applying.",
327
+ )
328
+ @click.pass_context
329
+ @cli_command("task-add")
330
+ @handle_keyboard_interrupt()
331
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Add task timed out")
332
+ def modify_task_add_cmd(
333
+ ctx: click.Context,
334
+ spec_id: str,
335
+ parent: str,
336
+ title: str,
337
+ description: Optional[str],
338
+ task_type: str,
339
+ hours: Optional[float],
340
+ position: Optional[int],
341
+ dry_run: bool,
342
+ ) -> None:
343
+ """Add a new task to a specification.
344
+
345
+ SPEC_ID is the specification identifier.
346
+ """
347
+ start_time = time.perf_counter()
348
+ cli_ctx = get_context(ctx)
349
+ specs_dir = cli_ctx.specs_dir
350
+
351
+ if specs_dir is None:
352
+ emit_error(
353
+ "No specs directory found",
354
+ code="VALIDATION_ERROR",
355
+ error_type="validation",
356
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
357
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
358
+ )
359
+ return
360
+
361
+ if dry_run:
362
+ # For dry_run, we just validate and preview without saving
363
+ # The native add_task function doesn't support dry_run directly,
364
+ # so we emit a preview response
365
+ duration_ms = (time.perf_counter() - start_time) * 1000
366
+ emit_success(
367
+ {
368
+ "spec_id": spec_id,
369
+ "parent": parent,
370
+ "title": title,
371
+ "type": task_type,
372
+ "dry_run": True,
373
+ "preview": {
374
+ "action": "add_task",
375
+ "description": description,
376
+ "estimated_hours": hours,
377
+ "position": position,
378
+ },
379
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
380
+ }
381
+ )
382
+ return
383
+
384
+ # Use native add_task function
385
+ result, error = add_task(
386
+ spec_id=spec_id,
387
+ parent_id=parent,
388
+ title=title,
389
+ description=description,
390
+ task_type=task_type,
391
+ estimated_hours=hours,
392
+ position=position,
393
+ specs_dir=specs_dir,
394
+ )
395
+
396
+ duration_ms = (time.perf_counter() - start_time) * 1000
397
+
398
+ if error:
399
+ emit_error(
400
+ f"Add task failed: {error}",
401
+ code="ADD_FAILED",
402
+ error_type="internal",
403
+ remediation="Check that the parent node exists and parameters are valid",
404
+ details={
405
+ "spec_id": spec_id,
406
+ "parent": parent,
407
+ },
408
+ )
409
+ return
410
+
411
+ emit_success(
412
+ {
413
+ "spec_id": spec_id,
414
+ "parent": parent,
415
+ "title": title,
416
+ "type": task_type,
417
+ "dry_run": False,
418
+ **result,
419
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
420
+ }
421
+ )
422
+
423
+
424
+ @modify_task_group.command("remove")
425
+ @click.argument("spec_id")
426
+ @click.argument("task_id")
427
+ @click.option(
428
+ "--cascade",
429
+ is_flag=True,
430
+ help="Also remove all child tasks recursively.",
431
+ )
432
+ @click.option(
433
+ "--dry-run",
434
+ is_flag=True,
435
+ help="Preview changes without applying.",
436
+ )
437
+ @click.pass_context
438
+ @cli_command("task-remove")
439
+ @handle_keyboard_interrupt()
440
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Remove task timed out")
441
+ def modify_task_remove_cmd(
442
+ ctx: click.Context,
443
+ spec_id: str,
444
+ task_id: str,
445
+ cascade: bool,
446
+ dry_run: bool,
447
+ ) -> None:
448
+ """Remove a task from a specification.
449
+
450
+ SPEC_ID is the specification identifier.
451
+ TASK_ID is the task to remove.
452
+ """
453
+ start_time = time.perf_counter()
454
+ cli_ctx = get_context(ctx)
455
+ specs_dir = cli_ctx.specs_dir
456
+
457
+ if specs_dir is None:
458
+ emit_error(
459
+ "No specs directory found",
460
+ code="VALIDATION_ERROR",
461
+ error_type="validation",
462
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
463
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
464
+ )
465
+ return
466
+
467
+ if dry_run:
468
+ # For dry_run, emit a preview response
469
+ duration_ms = (time.perf_counter() - start_time) * 1000
470
+ emit_success(
471
+ {
472
+ "spec_id": spec_id,
473
+ "task_id": task_id,
474
+ "cascade": cascade,
475
+ "dry_run": True,
476
+ "preview": {
477
+ "action": "remove_task",
478
+ "cascade": cascade,
479
+ },
480
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
481
+ }
482
+ )
483
+ return
484
+
485
+ # Use native remove_task function
486
+ result, error = remove_task(
487
+ spec_id=spec_id,
488
+ task_id=task_id,
489
+ cascade=cascade,
490
+ specs_dir=specs_dir,
491
+ )
492
+
493
+ duration_ms = (time.perf_counter() - start_time) * 1000
494
+
495
+ if error:
496
+ emit_error(
497
+ f"Remove task failed: {error}",
498
+ code="REMOVE_FAILED",
499
+ error_type="internal",
500
+ remediation="Verify task exists and spec structure is valid",
501
+ details={
502
+ "spec_id": spec_id,
503
+ "task_id": task_id,
504
+ },
505
+ )
506
+ return
507
+
508
+ emit_success(
509
+ {
510
+ "spec_id": spec_id,
511
+ "task_id": task_id,
512
+ "cascade": cascade,
513
+ "dry_run": False,
514
+ **result,
515
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
516
+ }
517
+ )
518
+
519
+
520
+ @modify_group.command("assumption")
521
+ @click.argument("spec_id")
522
+ @click.option(
523
+ "--text",
524
+ required=True,
525
+ help="Assumption text/description.",
526
+ )
527
+ @click.option(
528
+ "--type",
529
+ "assumption_type",
530
+ type=click.Choice(["constraint", "requirement"]),
531
+ help="Type of assumption.",
532
+ )
533
+ @click.option(
534
+ "--author",
535
+ help="Author who added the assumption.",
536
+ )
537
+ @click.option(
538
+ "--dry-run",
539
+ is_flag=True,
540
+ help="Preview changes without applying.",
541
+ )
542
+ @click.pass_context
543
+ @cli_command("assumption")
544
+ @handle_keyboard_interrupt()
545
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Add assumption timed out")
546
+ def modify_assumption_cmd(
547
+ ctx: click.Context,
548
+ spec_id: str,
549
+ text: str,
550
+ assumption_type: Optional[str],
551
+ author: Optional[str],
552
+ dry_run: bool,
553
+ ) -> None:
554
+ """Add an assumption to a specification.
555
+
556
+ SPEC_ID is the specification identifier.
557
+ """
558
+ start_time = time.perf_counter()
559
+ cli_ctx = get_context(ctx)
560
+ specs_dir = cli_ctx.specs_dir
561
+
562
+ if specs_dir is None:
563
+ emit_error(
564
+ "No specs directory found",
565
+ code="VALIDATION_ERROR",
566
+ error_type="validation",
567
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
568
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
569
+ )
570
+ return
571
+
572
+ if dry_run:
573
+ # For dry_run, emit a preview response
574
+ duration_ms = (time.perf_counter() - start_time) * 1000
575
+ emit_success(
576
+ {
577
+ "spec_id": spec_id,
578
+ "text": text,
579
+ "type": assumption_type or "constraint",
580
+ "dry_run": True,
581
+ "preview": {
582
+ "action": "add_assumption",
583
+ "author": author,
584
+ },
585
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
586
+ }
587
+ )
588
+ return
589
+
590
+ # Use native add_assumption function
591
+ result, error = add_assumption(
592
+ spec_id=spec_id,
593
+ text=text,
594
+ assumption_type=assumption_type or "constraint",
595
+ author=author,
596
+ specs_dir=specs_dir,
597
+ )
598
+
599
+ duration_ms = (time.perf_counter() - start_time) * 1000
600
+
601
+ if error:
602
+ emit_error(
603
+ f"Add assumption failed: {error}",
604
+ code="ADD_FAILED",
605
+ error_type="internal",
606
+ remediation="Verify spec exists and assumption format is valid",
607
+ details={
608
+ "spec_id": spec_id,
609
+ },
610
+ )
611
+ return
612
+
613
+ emit_success(
614
+ {
615
+ "spec_id": spec_id,
616
+ "text": text,
617
+ "type": assumption_type or "constraint",
618
+ "dry_run": False,
619
+ **result,
620
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
621
+ }
622
+ )
623
+
624
+
625
+ @modify_group.command("revision")
626
+ @click.argument("spec_id")
627
+ @click.option(
628
+ "--version",
629
+ required=True,
630
+ help="Revision version (e.g., 1.1, 2.0).",
631
+ )
632
+ @click.option(
633
+ "--changes",
634
+ required=True,
635
+ help="Summary of changes.",
636
+ )
637
+ @click.option(
638
+ "--author",
639
+ help="Revision author.",
640
+ )
641
+ @click.option(
642
+ "--dry-run",
643
+ is_flag=True,
644
+ help="Preview changes without applying.",
645
+ )
646
+ @click.pass_context
647
+ @cli_command("revision")
648
+ @handle_keyboard_interrupt()
649
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Add revision timed out")
650
+ def modify_revision_cmd(
651
+ ctx: click.Context,
652
+ spec_id: str,
653
+ version: str,
654
+ changes: str,
655
+ author: Optional[str],
656
+ dry_run: bool,
657
+ ) -> None:
658
+ """Add a revision history entry to a specification.
659
+
660
+ SPEC_ID is the specification identifier.
661
+ """
662
+ start_time = time.perf_counter()
663
+ cli_ctx = get_context(ctx)
664
+ specs_dir = cli_ctx.specs_dir
665
+
666
+ if specs_dir is None:
667
+ emit_error(
668
+ "No specs directory found",
669
+ code="VALIDATION_ERROR",
670
+ error_type="validation",
671
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
672
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
673
+ )
674
+ return
675
+
676
+ if dry_run:
677
+ # For dry_run, emit a preview response
678
+ duration_ms = (time.perf_counter() - start_time) * 1000
679
+ emit_success(
680
+ {
681
+ "spec_id": spec_id,
682
+ "version": version,
683
+ "changes": changes,
684
+ "dry_run": True,
685
+ "preview": {
686
+ "action": "add_revision",
687
+ "author": author,
688
+ },
689
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
690
+ }
691
+ )
692
+ return
693
+
694
+ # Use native add_revision function
695
+ result, error = add_revision(
696
+ spec_id=spec_id,
697
+ version=version,
698
+ changelog=changes,
699
+ author=author,
700
+ specs_dir=specs_dir,
701
+ )
702
+
703
+ duration_ms = (time.perf_counter() - start_time) * 1000
704
+
705
+ if error:
706
+ emit_error(
707
+ f"Add revision failed: {error}",
708
+ code="ADD_FAILED",
709
+ error_type="internal",
710
+ remediation="Verify spec exists and revision format is valid",
711
+ details={
712
+ "spec_id": spec_id,
713
+ },
714
+ )
715
+ return
716
+
717
+ emit_success(
718
+ {
719
+ "spec_id": spec_id,
720
+ "version": version,
721
+ "changes": changes,
722
+ "dry_run": False,
723
+ **result,
724
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
725
+ }
726
+ )
727
+
728
+
729
+ @modify_group.command("frontmatter")
730
+ @click.argument("spec_id")
731
+ @click.option(
732
+ "--key",
733
+ required=True,
734
+ help="Frontmatter key to update (e.g., title, status, version).",
735
+ )
736
+ @click.option(
737
+ "--value",
738
+ required=True,
739
+ help="New value for the key.",
740
+ )
741
+ @click.option(
742
+ "--dry-run",
743
+ is_flag=True,
744
+ help="Preview changes without applying.",
745
+ )
746
+ @click.pass_context
747
+ @cli_command("frontmatter")
748
+ @handle_keyboard_interrupt()
749
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Update frontmatter timed out")
750
+ def modify_frontmatter_cmd(
751
+ ctx: click.Context,
752
+ spec_id: str,
753
+ key: str,
754
+ value: str,
755
+ dry_run: bool,
756
+ ) -> None:
757
+ """Update frontmatter metadata in a specification.
758
+
759
+ SPEC_ID is the specification identifier.
760
+ """
761
+ start_time = time.perf_counter()
762
+ cli_ctx = get_context(ctx)
763
+ specs_dir = cli_ctx.specs_dir
764
+
765
+ if specs_dir is None:
766
+ emit_error(
767
+ "No specs directory found",
768
+ code="VALIDATION_ERROR",
769
+ error_type="validation",
770
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
771
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
772
+ )
773
+ return
774
+
775
+ if dry_run:
776
+ # For dry_run, emit a preview response
777
+ duration_ms = (time.perf_counter() - start_time) * 1000
778
+ emit_success(
779
+ {
780
+ "spec_id": spec_id,
781
+ "key": key,
782
+ "value": value,
783
+ "dry_run": True,
784
+ "preview": {
785
+ "action": "update_frontmatter",
786
+ },
787
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
788
+ }
789
+ )
790
+ return
791
+
792
+ # Use native update_frontmatter function
793
+ result, error = update_frontmatter(
794
+ spec_id=spec_id,
795
+ key=key,
796
+ value=value,
797
+ specs_dir=specs_dir,
798
+ )
799
+
800
+ duration_ms = (time.perf_counter() - start_time) * 1000
801
+
802
+ if error:
803
+ emit_error(
804
+ f"Update frontmatter failed: {error}",
805
+ code="UPDATE_FAILED",
806
+ error_type="internal",
807
+ remediation="Verify spec exists and frontmatter key is valid",
808
+ details={
809
+ "spec_id": spec_id,
810
+ "key": key,
811
+ },
812
+ )
813
+ return
814
+
815
+ emit_success(
816
+ {
817
+ "spec_id": spec_id,
818
+ "key": key,
819
+ "value": value,
820
+ "dry_run": False,
821
+ **result,
822
+ "telemetry": {"duration_ms": round(duration_ms, 2)},
823
+ }
824
+ )