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,807 @@
1
+ """Task management commands for SDD CLI.
2
+
3
+ Provides commands for discovering, querying, and updating tasks in specifications.
4
+ """
5
+
6
+ from typing import Any, Dict, Optional
7
+
8
+ import click
9
+
10
+ from foundry_mcp.cli.logging import cli_command, get_cli_logger
11
+ from foundry_mcp.cli.output import emit_error, emit_success
12
+ from foundry_mcp.cli.resilience import (
13
+ handle_keyboard_interrupt,
14
+ MEDIUM_TIMEOUT,
15
+ with_sync_timeout,
16
+ )
17
+ from foundry_mcp.cli.registry import get_context
18
+
19
+ logger = get_cli_logger()
20
+ from foundry_mcp.core.spec import load_spec, find_spec_file, get_node
21
+ from foundry_mcp.core.journal import (
22
+ add_journal_entry,
23
+ mark_blocked,
24
+ save_journal,
25
+ unblock,
26
+ update_task_status,
27
+ )
28
+ from foundry_mcp.core.task import (
29
+ check_dependencies,
30
+ get_next_task,
31
+ get_parent_context,
32
+ get_phase_context,
33
+ get_previous_sibling,
34
+ get_task_journal_summary,
35
+ prepare_task,
36
+ )
37
+
38
+
39
+ @click.group("tasks")
40
+ def tasks() -> None:
41
+ """Task management commands."""
42
+ pass
43
+
44
+
45
+ @tasks.command("next")
46
+ @click.argument("spec_id")
47
+ @click.pass_context
48
+ @cli_command("next")
49
+ @handle_keyboard_interrupt()
50
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Task discovery timed out")
51
+ def next_task(ctx: click.Context, spec_id: str) -> None:
52
+ """Find the next actionable task in a specification.
53
+
54
+ SPEC_ID is the specification identifier.
55
+ """
56
+ cli_ctx = get_context(ctx)
57
+ specs_dir = cli_ctx.specs_dir
58
+
59
+ if specs_dir is None:
60
+ emit_error(
61
+ "No specs directory found",
62
+ code="VALIDATION_ERROR",
63
+ error_type="validation",
64
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
65
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
66
+ )
67
+
68
+ # Load the spec
69
+ spec_data = load_spec(spec_id, specs_dir)
70
+ if spec_data is None:
71
+ emit_error(
72
+ f"Specification not found: {spec_id}",
73
+ code="SPEC_NOT_FOUND",
74
+ error_type="not_found",
75
+ remediation="Verify the spec ID exists using: sdd specs list",
76
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
77
+ )
78
+
79
+ # Find the next task
80
+ result = get_next_task(spec_data)
81
+
82
+ if result:
83
+ task_id, task_data = result
84
+ emit_success(
85
+ {
86
+ "found": True,
87
+ "spec_id": spec_id,
88
+ "task_id": task_id,
89
+ "title": task_data.get("title", ""),
90
+ "type": task_data.get("type", "task"),
91
+ "status": task_data.get("status", "pending"),
92
+ "metadata": task_data.get("metadata", {}),
93
+ }
94
+ )
95
+ else:
96
+ # Check if spec is complete or blocked
97
+ hierarchy = spec_data.get("hierarchy", {})
98
+ all_tasks = [
99
+ node
100
+ for node in hierarchy.values()
101
+ if isinstance(node, dict)
102
+ and node.get("type") in ("task", "subtask", "verify")
103
+ ]
104
+ completed = sum(1 for t in all_tasks if t.get("status") == "completed")
105
+ pending = sum(1 for t in all_tasks if t.get("status") == "pending")
106
+
107
+ if pending == 0 and completed > 0:
108
+ emit_success(
109
+ {
110
+ "found": False,
111
+ "spec_id": spec_id,
112
+ "spec_complete": True,
113
+ "message": "All tasks completed",
114
+ }
115
+ )
116
+ else:
117
+ emit_success(
118
+ {
119
+ "found": False,
120
+ "spec_id": spec_id,
121
+ "spec_complete": False,
122
+ "message": "No actionable tasks (tasks may be blocked)",
123
+ }
124
+ )
125
+
126
+
127
+ @tasks.command("prepare")
128
+ @click.argument("spec_id")
129
+ @click.argument("task_id", required=False)
130
+ @click.pass_context
131
+ @cli_command("prepare")
132
+ @handle_keyboard_interrupt()
133
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Task preparation timed out")
134
+ def prepare_task_cmd(
135
+ ctx: click.Context, spec_id: str, task_id: Optional[str] = None
136
+ ) -> None:
137
+ """Prepare complete context for task implementation.
138
+
139
+ SPEC_ID is the specification identifier.
140
+ TASK_ID is optional; auto-discovers next task if not provided.
141
+ """
142
+ cli_ctx = get_context(ctx)
143
+ specs_dir = cli_ctx.specs_dir
144
+
145
+ if specs_dir is None:
146
+ emit_error(
147
+ "No specs directory found",
148
+ code="VALIDATION_ERROR",
149
+ error_type="validation",
150
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
151
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
152
+ )
153
+
154
+ # Use core prepare_task function
155
+ result = prepare_task(spec_id, specs_dir, task_id)
156
+
157
+ # Check if result indicates an error (from error_response)
158
+ if result.get("success") is False:
159
+ error = result.get("error", {})
160
+ emit_error(
161
+ error.get("message", "Task preparation failed"),
162
+ code=error.get("code", "INTERNAL_ERROR"),
163
+ error_type="internal",
164
+ remediation="Check the spec file exists and is valid",
165
+ details={"spec_id": spec_id, "task_id": task_id},
166
+ )
167
+
168
+ # Extract data from success response
169
+ data = result.get("data", result)
170
+
171
+ emit_success(
172
+ {
173
+ "spec_id": spec_id,
174
+ "task_id": data.get("task_id"),
175
+ "spec_complete": data.get("spec_complete", False),
176
+ "task_data": data.get("task_data"),
177
+ "dependencies": data.get("dependencies"),
178
+ "context": data.get("context"),
179
+ }
180
+ )
181
+
182
+
183
+ @tasks.command("info")
184
+ @click.argument("spec_id")
185
+ @click.argument("task_id")
186
+ @click.option(
187
+ "--include-context/--no-context", default=True, help="Include task context."
188
+ )
189
+ @click.pass_context
190
+ @cli_command("info")
191
+ @handle_keyboard_interrupt()
192
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Task info lookup timed out")
193
+ def task_info_cmd(
194
+ ctx: click.Context,
195
+ spec_id: str,
196
+ task_id: str,
197
+ include_context: bool,
198
+ ) -> None:
199
+ """Get detailed information about a specific task.
200
+
201
+ SPEC_ID is the specification identifier.
202
+ TASK_ID is the task identifier.
203
+ """
204
+ cli_ctx = get_context(ctx)
205
+ specs_dir = cli_ctx.specs_dir
206
+
207
+ if specs_dir is None:
208
+ emit_error(
209
+ "No specs directory found",
210
+ code="VALIDATION_ERROR",
211
+ error_type="validation",
212
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
213
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
214
+ )
215
+
216
+ # Load the spec
217
+ spec_data = load_spec(spec_id, specs_dir)
218
+ if spec_data is None:
219
+ emit_error(
220
+ f"Specification not found: {spec_id}",
221
+ code="SPEC_NOT_FOUND",
222
+ error_type="not_found",
223
+ remediation="Verify the spec ID exists using: sdd specs list",
224
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
225
+ )
226
+
227
+ # Get task data
228
+ task_data = get_node(spec_data, task_id)
229
+ if task_data is None:
230
+ emit_error(
231
+ f"Task not found: {task_id}",
232
+ code="TASK_NOT_FOUND",
233
+ error_type="not_found",
234
+ remediation="Verify the task ID exists using: sdd tasks info <spec_id> --list",
235
+ details={"spec_id": spec_id, "task_id": task_id},
236
+ )
237
+
238
+ # Check dependencies
239
+ deps = check_dependencies(spec_data, task_id)
240
+
241
+ result: Dict[str, Any] = {
242
+ "spec_id": spec_id,
243
+ "task_id": task_id,
244
+ "title": task_data.get("title", ""),
245
+ "type": task_data.get("type", "task"),
246
+ "status": task_data.get("status", "pending"),
247
+ "metadata": task_data.get("metadata", {}),
248
+ "children": task_data.get("children", []),
249
+ "dependencies": deps,
250
+ }
251
+
252
+ # Add context if requested
253
+ if include_context:
254
+ result["context"] = {
255
+ "previous_sibling": get_previous_sibling(spec_data, task_id),
256
+ "parent_task": get_parent_context(spec_data, task_id),
257
+ "phase": get_phase_context(spec_data, task_id),
258
+ "task_journal": get_task_journal_summary(spec_data, task_id),
259
+ }
260
+
261
+ emit_success(result)
262
+
263
+
264
+ @tasks.command("update-status")
265
+ @click.argument("spec_id")
266
+ @click.argument("task_id")
267
+ @click.argument(
268
+ "status", type=click.Choice(["pending", "in_progress", "completed", "blocked"])
269
+ )
270
+ @click.option("--note", "-n", help="Optional note about the status change.")
271
+ @click.pass_context
272
+ @cli_command("update-status")
273
+ @handle_keyboard_interrupt()
274
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Status update timed out")
275
+ def update_status_cmd(
276
+ ctx: click.Context,
277
+ spec_id: str,
278
+ task_id: str,
279
+ status: str,
280
+ note: Optional[str],
281
+ ) -> None:
282
+ """Update a task's status.
283
+
284
+ SPEC_ID is the specification identifier.
285
+ TASK_ID is the task identifier.
286
+ STATUS is one of: pending, in_progress, completed, blocked.
287
+ """
288
+ cli_ctx = get_context(ctx)
289
+ specs_dir = cli_ctx.specs_dir
290
+
291
+ if specs_dir is None:
292
+ emit_error(
293
+ "No specs directory found",
294
+ code="VALIDATION_ERROR",
295
+ error_type="validation",
296
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
297
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
298
+ )
299
+
300
+ # Find and load spec
301
+ spec_path = find_spec_file(spec_id, specs_dir)
302
+ if spec_path is None:
303
+ emit_error(
304
+ f"Specification not found: {spec_id}",
305
+ code="SPEC_NOT_FOUND",
306
+ error_type="not_found",
307
+ remediation="Verify the spec ID exists using: sdd specs list",
308
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
309
+ )
310
+
311
+ spec_data = load_spec(spec_id, specs_dir)
312
+ if spec_data is None:
313
+ emit_error(
314
+ f"Failed to load specification: {spec_id}",
315
+ code="INTERNAL_ERROR",
316
+ error_type="internal",
317
+ remediation="Check that the spec file is valid JSON",
318
+ details={"spec_id": spec_id},
319
+ )
320
+
321
+ # Update status
322
+ success = update_task_status(spec_data, task_id, status, note)
323
+ if not success:
324
+ emit_error(
325
+ f"Failed to update task status: {task_id}",
326
+ code="INTERNAL_ERROR",
327
+ error_type="internal",
328
+ remediation="Verify the task ID exists and the status transition is valid",
329
+ details={"task_id": task_id, "status": status},
330
+ )
331
+
332
+ # Save changes
333
+ if not save_journal(spec_data, str(spec_path), create_backup=True):
334
+ emit_error(
335
+ "Failed to save spec file",
336
+ code="INTERNAL_ERROR",
337
+ error_type="internal",
338
+ remediation="Check file permissions and disk space",
339
+ details={"path": str(spec_path)},
340
+ )
341
+
342
+ emit_success(
343
+ {
344
+ "spec_id": spec_id,
345
+ "task_id": task_id,
346
+ "status": status,
347
+ "note": note,
348
+ }
349
+ )
350
+
351
+
352
+ @tasks.command("block")
353
+ @click.argument("spec_id")
354
+ @click.argument("task_id")
355
+ @click.option("--reason", "-r", required=True, help="Description of the blocker.")
356
+ @click.option(
357
+ "--type",
358
+ "-t",
359
+ "blocker_type",
360
+ type=click.Choice(["dependency", "technical", "resource", "decision"]),
361
+ default="dependency",
362
+ help="Type of blocker.",
363
+ )
364
+ @click.option("--ticket", help="Optional ticket/issue reference.")
365
+ @click.pass_context
366
+ @cli_command("block")
367
+ @handle_keyboard_interrupt()
368
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Block task timed out")
369
+ def block_task_cmd(
370
+ ctx: click.Context,
371
+ spec_id: str,
372
+ task_id: str,
373
+ reason: str,
374
+ blocker_type: str,
375
+ ticket: Optional[str],
376
+ ) -> None:
377
+ """Mark a task as blocked.
378
+
379
+ SPEC_ID is the specification identifier.
380
+ TASK_ID is the task identifier.
381
+ """
382
+ cli_ctx = get_context(ctx)
383
+ specs_dir = cli_ctx.specs_dir
384
+
385
+ if specs_dir is None:
386
+ emit_error(
387
+ "No specs directory found",
388
+ code="VALIDATION_ERROR",
389
+ error_type="validation",
390
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
391
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
392
+ )
393
+
394
+ # Find and load spec
395
+ spec_path = find_spec_file(spec_id, specs_dir)
396
+ if spec_path is None:
397
+ emit_error(
398
+ f"Specification not found: {spec_id}",
399
+ code="SPEC_NOT_FOUND",
400
+ error_type="not_found",
401
+ remediation="Verify the spec ID exists using: sdd specs list",
402
+ details={"spec_id": spec_id},
403
+ )
404
+
405
+ spec_data = load_spec(spec_id, specs_dir)
406
+ if spec_data is None:
407
+ emit_error(
408
+ f"Failed to load specification: {spec_id}",
409
+ code="INTERNAL_ERROR",
410
+ error_type="internal",
411
+ remediation="Check that the spec file is valid JSON",
412
+ details={"spec_id": spec_id},
413
+ )
414
+
415
+ # Mark blocked
416
+ success = mark_blocked(spec_data, task_id, reason, blocker_type, ticket)
417
+ if not success:
418
+ emit_error(
419
+ f"Failed to block task: {task_id}",
420
+ code="INTERNAL_ERROR",
421
+ error_type="internal",
422
+ remediation="Verify the task ID exists",
423
+ details={"task_id": task_id},
424
+ )
425
+
426
+ # Save changes
427
+ if not save_journal(spec_data, str(spec_path), create_backup=True):
428
+ emit_error(
429
+ "Failed to save spec file",
430
+ code="INTERNAL_ERROR",
431
+ error_type="internal",
432
+ remediation="Check file permissions and disk space",
433
+ details={"path": str(spec_path)},
434
+ )
435
+
436
+ emit_success(
437
+ {
438
+ "spec_id": spec_id,
439
+ "task_id": task_id,
440
+ "status": "blocked",
441
+ "blocker_type": blocker_type,
442
+ "reason": reason,
443
+ "ticket": ticket,
444
+ }
445
+ )
446
+
447
+
448
+ @tasks.command("unblock")
449
+ @click.argument("spec_id")
450
+ @click.argument("task_id")
451
+ @click.option("--resolution", "-r", help="Description of how blocker was resolved.")
452
+ @click.option(
453
+ "--status",
454
+ "-s",
455
+ type=click.Choice(["pending", "in_progress"]),
456
+ default="pending",
457
+ help="Status after unblocking.",
458
+ )
459
+ @click.pass_context
460
+ @cli_command("unblock")
461
+ @handle_keyboard_interrupt()
462
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Unblock task timed out")
463
+ def unblock_task_cmd(
464
+ ctx: click.Context,
465
+ spec_id: str,
466
+ task_id: str,
467
+ resolution: Optional[str],
468
+ status: str,
469
+ ) -> None:
470
+ """Unblock a task.
471
+
472
+ SPEC_ID is the specification identifier.
473
+ TASK_ID is the task identifier.
474
+ """
475
+ cli_ctx = get_context(ctx)
476
+ specs_dir = cli_ctx.specs_dir
477
+
478
+ if specs_dir is None:
479
+ emit_error(
480
+ "No specs directory found",
481
+ code="VALIDATION_ERROR",
482
+ error_type="validation",
483
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
484
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
485
+ )
486
+
487
+ # Find and load spec
488
+ spec_path = find_spec_file(spec_id, specs_dir)
489
+ if spec_path is None:
490
+ emit_error(
491
+ f"Specification not found: {spec_id}",
492
+ code="SPEC_NOT_FOUND",
493
+ error_type="not_found",
494
+ remediation="Verify the spec ID exists using: sdd specs list",
495
+ details={"spec_id": spec_id},
496
+ )
497
+
498
+ spec_data = load_spec(spec_id, specs_dir)
499
+ if spec_data is None:
500
+ emit_error(
501
+ f"Failed to load specification: {spec_id}",
502
+ code="INTERNAL_ERROR",
503
+ error_type="internal",
504
+ remediation="Check that the spec file is valid JSON",
505
+ details={"spec_id": spec_id},
506
+ )
507
+
508
+ # Unblock
509
+ success = unblock(spec_data, task_id, resolution, status)
510
+ if not success:
511
+ emit_error(
512
+ f"Failed to unblock task: {task_id}",
513
+ code="CONFLICT",
514
+ error_type="conflict",
515
+ remediation="Verify the task is currently blocked",
516
+ details={"task_id": task_id, "hint": "Task may not be blocked"},
517
+ )
518
+
519
+ # Save changes
520
+ if not save_journal(spec_data, str(spec_path), create_backup=True):
521
+ emit_error(
522
+ "Failed to save spec file",
523
+ code="INTERNAL_ERROR",
524
+ error_type="internal",
525
+ remediation="Check file permissions and disk space",
526
+ details={"path": str(spec_path)},
527
+ )
528
+
529
+ emit_success(
530
+ {
531
+ "spec_id": spec_id,
532
+ "task_id": task_id,
533
+ "status": status,
534
+ "resolution": resolution,
535
+ }
536
+ )
537
+
538
+
539
+ @tasks.command("complete")
540
+ @click.argument("spec_id")
541
+ @click.argument("task_id")
542
+ @click.option(
543
+ "--note",
544
+ "-n",
545
+ required=True,
546
+ help="Completion note describing what was accomplished.",
547
+ )
548
+ @click.pass_context
549
+ @cli_command("complete")
550
+ @handle_keyboard_interrupt()
551
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Task completion timed out")
552
+ def complete_task_cmd(
553
+ ctx: click.Context,
554
+ spec_id: str,
555
+ task_id: str,
556
+ note: str,
557
+ ) -> None:
558
+ """Mark a task as completed with auto-journaling.
559
+
560
+ SPEC_ID is the specification identifier.
561
+ TASK_ID is the task identifier.
562
+
563
+ Combines status update to 'completed' with automatic journal entry creation.
564
+ The --note is required and should describe what was accomplished.
565
+ """
566
+ cli_ctx = get_context(ctx)
567
+ specs_dir = cli_ctx.specs_dir
568
+
569
+ if specs_dir is None:
570
+ emit_error(
571
+ "No specs directory found",
572
+ code="VALIDATION_ERROR",
573
+ error_type="validation",
574
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
575
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
576
+ )
577
+
578
+ # Find and load spec
579
+ spec_path = find_spec_file(spec_id, specs_dir)
580
+ if spec_path is None:
581
+ emit_error(
582
+ f"Specification not found: {spec_id}",
583
+ code="SPEC_NOT_FOUND",
584
+ error_type="not_found",
585
+ remediation="Verify the spec ID exists using: sdd specs list",
586
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
587
+ )
588
+
589
+ spec_data = load_spec(spec_id, specs_dir)
590
+ if spec_data is None:
591
+ emit_error(
592
+ f"Failed to load specification: {spec_id}",
593
+ code="INTERNAL_ERROR",
594
+ error_type="internal",
595
+ remediation="Check that the spec file is valid JSON",
596
+ details={"spec_id": spec_id},
597
+ )
598
+
599
+ # Get task info before updating
600
+ task_data = get_node(spec_data, task_id)
601
+ if task_data is None:
602
+ emit_error(
603
+ f"Task not found: {task_id}",
604
+ code="TASK_NOT_FOUND",
605
+ error_type="not_found",
606
+ remediation="Verify the task ID exists using: sdd tasks info <spec_id> --list",
607
+ details={"spec_id": spec_id, "task_id": task_id},
608
+ )
609
+
610
+ task_title = task_data.get("title", task_id)
611
+ # Capture previous status before updating (task_data is a reference)
612
+ previous_status = task_data.get("status", "unknown")
613
+
614
+ # Update status to completed
615
+ success = update_task_status(spec_data, task_id, "completed", note)
616
+ if not success:
617
+ emit_error(
618
+ f"Failed to update task status: {task_id}",
619
+ code="INTERNAL_ERROR",
620
+ error_type="internal",
621
+ remediation="Verify the task ID exists and the status transition is valid",
622
+ details={"task_id": task_id, "status": "completed"},
623
+ )
624
+
625
+ # Create journal entry for the completion
626
+ journal_title = f"Completed: {task_title}"
627
+ entry = add_journal_entry(
628
+ spec_data,
629
+ title=journal_title,
630
+ content=note,
631
+ entry_type="status_change",
632
+ task_id=task_id,
633
+ author="claude-code",
634
+ metadata={"previous_status": previous_status},
635
+ )
636
+
637
+ # Save changes
638
+ if not save_journal(spec_data, str(spec_path), create_backup=True):
639
+ emit_error(
640
+ "Failed to save spec file",
641
+ code="INTERNAL_ERROR",
642
+ error_type="internal",
643
+ remediation="Check file permissions and disk space",
644
+ details={"path": str(spec_path)},
645
+ )
646
+
647
+ emit_success(
648
+ {
649
+ "spec_id": spec_id,
650
+ "task_id": task_id,
651
+ "status": "completed",
652
+ "title": task_title,
653
+ "journal_entry": {
654
+ "timestamp": entry.timestamp,
655
+ "title": entry.title,
656
+ "entry_type": entry.entry_type,
657
+ },
658
+ "note": note,
659
+ }
660
+ )
661
+
662
+
663
+ @tasks.command("check-complete")
664
+ @click.argument("spec_id")
665
+ @click.argument("task_id")
666
+ @click.pass_context
667
+ @cli_command("check-complete")
668
+ @handle_keyboard_interrupt()
669
+ @with_sync_timeout(MEDIUM_TIMEOUT, "Check complete timed out")
670
+ def check_complete_cmd(
671
+ ctx: click.Context,
672
+ spec_id: str,
673
+ task_id: str,
674
+ ) -> None:
675
+ """Check if a task can be marked as complete.
676
+
677
+ SPEC_ID is the specification identifier.
678
+ TASK_ID is the task identifier.
679
+
680
+ Returns whether the task can be completed and any blockers preventing completion.
681
+ Checks:
682
+ - Task exists and is not already completed
683
+ - All child tasks are completed (for group/phase tasks)
684
+ - All dependencies are satisfied
685
+ - Task is not blocked
686
+ """
687
+ cli_ctx = get_context(ctx)
688
+ specs_dir = cli_ctx.specs_dir
689
+
690
+ if specs_dir is None:
691
+ emit_error(
692
+ "No specs directory found",
693
+ code="VALIDATION_ERROR",
694
+ error_type="validation",
695
+ remediation="Use --specs-dir option or set SDD_SPECS_DIR environment variable",
696
+ details={"hint": "Use --specs-dir or set SDD_SPECS_DIR"},
697
+ )
698
+ return
699
+
700
+ # Load the spec
701
+ spec_data = load_spec(spec_id, specs_dir)
702
+ if spec_data is None:
703
+ emit_error(
704
+ f"Specification not found: {spec_id}",
705
+ code="SPEC_NOT_FOUND",
706
+ error_type="not_found",
707
+ remediation="Verify the spec ID exists using: sdd specs list",
708
+ details={"spec_id": spec_id, "specs_dir": str(specs_dir)},
709
+ )
710
+ return
711
+
712
+ # Get task data
713
+ task_data = get_node(spec_data, task_id)
714
+ if task_data is None:
715
+ emit_error(
716
+ f"Task not found: {task_id}",
717
+ code="TASK_NOT_FOUND",
718
+ error_type="not_found",
719
+ remediation="Verify the task ID exists using: sdd tasks info <spec_id> --list",
720
+ details={"spec_id": spec_id, "task_id": task_id},
721
+ )
722
+ return
723
+
724
+ blockers = []
725
+ can_complete = True
726
+
727
+ # Check if already completed
728
+ current_status = task_data.get("status", "pending")
729
+ if current_status == "completed":
730
+ emit_success(
731
+ {
732
+ "spec_id": spec_id,
733
+ "task_id": task_id,
734
+ "can_complete": True,
735
+ "already_completed": True,
736
+ "status": current_status,
737
+ "blockers": [],
738
+ "message": "Task is already completed",
739
+ }
740
+ )
741
+ return
742
+
743
+ # Check if task is blocked
744
+ if current_status == "blocked":
745
+ can_complete = False
746
+ blocker_info = task_data.get("metadata", {}).get("blocker", {})
747
+ blockers.append(
748
+ {
749
+ "type": "blocked_status",
750
+ "reason": blocker_info.get("reason", "Task is marked as blocked"),
751
+ "blocker_type": blocker_info.get("type", "unknown"),
752
+ }
753
+ )
754
+
755
+ # Check dependencies
756
+ deps = check_dependencies(spec_data, task_id)
757
+ if not deps.get("can_start", True):
758
+ can_complete = False
759
+ blocked_by = deps.get("blocked_by", [])
760
+ for dep in blocked_by:
761
+ blockers.append(
762
+ {
763
+ "type": "dependency",
764
+ "reason": f"Depends on incomplete task: {dep}",
765
+ "blocking_task": dep,
766
+ }
767
+ )
768
+
769
+ # Check child tasks for group/phase tasks
770
+ children = task_data.get("children", [])
771
+ if children:
772
+ hierarchy = spec_data.get("hierarchy", {})
773
+ incomplete_children = []
774
+ for child_id in children:
775
+ child_data = hierarchy.get(child_id)
776
+ if child_data and child_data.get("status") != "completed":
777
+ incomplete_children.append(
778
+ {
779
+ "id": child_id,
780
+ "title": child_data.get("title", child_id),
781
+ "status": child_data.get("status", "pending"),
782
+ }
783
+ )
784
+
785
+ if incomplete_children:
786
+ can_complete = False
787
+ blockers.append(
788
+ {
789
+ "type": "incomplete_children",
790
+ "reason": f"{len(incomplete_children)} child task(s) not completed",
791
+ "children": incomplete_children,
792
+ }
793
+ )
794
+
795
+ emit_success(
796
+ {
797
+ "spec_id": spec_id,
798
+ "task_id": task_id,
799
+ "can_complete": can_complete,
800
+ "already_completed": False,
801
+ "status": current_status,
802
+ "blockers": blockers,
803
+ "message": "Ready to complete"
804
+ if can_complete
805
+ else f"{len(blockers)} blocker(s) found",
806
+ }
807
+ )