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,1487 @@
1
+ """Unified authoring tool backed by ActionRouter and shared validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from mcp.server.fastmcp import FastMCP
12
+
13
+ from foundry_mcp.config import ServerConfig
14
+ from foundry_mcp.core.context import generate_correlation_id, get_correlation_id
15
+ from foundry_mcp.core.naming import canonical_tool
16
+ from foundry_mcp.core.observability import audit_log, get_metrics, mcp_tool
17
+ from foundry_mcp.core.responses import (
18
+ ErrorCode,
19
+ ErrorType,
20
+ error_response,
21
+ sanitize_error_message,
22
+ success_response,
23
+ )
24
+ from foundry_mcp.core.spec import (
25
+ ASSUMPTION_TYPES,
26
+ CATEGORIES,
27
+ TEMPLATES,
28
+ add_assumption,
29
+ add_phase,
30
+ add_revision,
31
+ create_spec,
32
+ find_specs_directory,
33
+ list_assumptions,
34
+ load_spec,
35
+ remove_phase,
36
+ update_frontmatter,
37
+ )
38
+ from foundry_mcp.tools.unified.router import (
39
+ ActionDefinition,
40
+ ActionRouter,
41
+ ActionRouterError,
42
+ )
43
+
44
+ logger = logging.getLogger(__name__)
45
+ _metrics = get_metrics()
46
+
47
+ _ACTION_SUMMARY = {
48
+ "spec-create": "Scaffold a new SDD specification",
49
+ "spec-template": "List/show/apply spec templates",
50
+ "spec-update-frontmatter": "Update a top-level metadata field",
51
+ "phase-add": "Add a new phase under spec-root with verification scaffolding",
52
+ "phase-remove": "Remove an existing phase (and optionally dependents)",
53
+ "assumption-add": "Append an assumption entry to spec metadata",
54
+ "assumption-list": "List recorded assumptions for a spec",
55
+ "revision-add": "Record a revision entry in the spec history",
56
+ }
57
+
58
+
59
+ def _metric_name(action: str) -> str:
60
+ return f"authoring.{action.replace('-', '_')}"
61
+
62
+
63
+ def _request_id() -> str:
64
+ return get_correlation_id() or generate_correlation_id(prefix="authoring")
65
+
66
+
67
+ def _validation_error(
68
+ *,
69
+ field: str,
70
+ action: str,
71
+ message: str,
72
+ request_id: str,
73
+ code: ErrorCode = ErrorCode.VALIDATION_ERROR,
74
+ remediation: Optional[str] = None,
75
+ ) -> dict:
76
+ return asdict(
77
+ error_response(
78
+ f"Invalid field '{field}' for authoring.{action}: {message}",
79
+ error_code=code,
80
+ error_type=ErrorType.VALIDATION,
81
+ remediation=remediation,
82
+ details={"field": field, "action": f"authoring.{action}"},
83
+ request_id=request_id,
84
+ )
85
+ )
86
+
87
+
88
+ def _specs_directory_missing_error(request_id: str) -> dict:
89
+ return asdict(
90
+ error_response(
91
+ "No specs directory found. Use specs_dir parameter or set SDD_SPECS_DIR.",
92
+ error_code=ErrorCode.NOT_FOUND,
93
+ error_type=ErrorType.NOT_FOUND,
94
+ remediation="Use --specs-dir or set SDD_SPECS_DIR",
95
+ request_id=request_id,
96
+ )
97
+ )
98
+
99
+
100
+ def _resolve_specs_dir(config: ServerConfig, path: Optional[str]) -> Optional[Path]:
101
+ try:
102
+ if path:
103
+ return find_specs_directory(path)
104
+ return config.specs_dir or find_specs_directory()
105
+ except Exception: # pragma: no cover - defensive guard
106
+ logger.exception("Failed to resolve specs directory", extra={"path": path})
107
+ return None
108
+
109
+
110
+ def _phase_exists(spec_id: str, specs_dir: Path, title: str) -> bool:
111
+ try:
112
+ spec_data = load_spec(spec_id, specs_dir)
113
+ except Exception: # pragma: no cover - defensive guard
114
+ logger.exception(
115
+ "Failed to inspect spec for duplicate phases", extra={"spec_id": spec_id}
116
+ )
117
+ return False
118
+
119
+ if not spec_data:
120
+ return False
121
+
122
+ hierarchy = spec_data.get("hierarchy", {})
123
+ if not isinstance(hierarchy, dict):
124
+ return False
125
+
126
+ normalized = title.strip().casefold()
127
+ for node in hierarchy.values():
128
+ if isinstance(node, dict) and node.get("type") == "phase":
129
+ node_title = str(node.get("title", "")).strip().casefold()
130
+ if node_title and node_title == normalized:
131
+ return True
132
+ return False
133
+
134
+
135
+ def _assumption_exists(spec_id: str, specs_dir: Path, text: str) -> bool:
136
+ result, error = list_assumptions(spec_id=spec_id, specs_dir=specs_dir)
137
+ if error or not result:
138
+ return False
139
+
140
+ normalized = text.strip().casefold()
141
+ for entry in result.get("assumptions", []):
142
+ entry_text = str(entry.get("text", "")).strip().casefold()
143
+ if entry_text and entry_text == normalized:
144
+ return True
145
+ return False
146
+
147
+
148
+ def _handle_spec_create(*, config: ServerConfig, **payload: Any) -> dict:
149
+ request_id = _request_id()
150
+ action = "spec-create"
151
+
152
+ name = payload.get("name")
153
+ if not isinstance(name, str) or not name.strip():
154
+ return _validation_error(
155
+ field="name",
156
+ action=action,
157
+ message="Provide a non-empty specification name",
158
+ request_id=request_id,
159
+ code=ErrorCode.MISSING_REQUIRED,
160
+ )
161
+
162
+ template = payload.get("template") or "medium"
163
+ if not isinstance(template, str):
164
+ return _validation_error(
165
+ field="template",
166
+ action=action,
167
+ message="template must be a string",
168
+ request_id=request_id,
169
+ code=ErrorCode.INVALID_FORMAT,
170
+ )
171
+ template = template.strip() or "medium"
172
+ if template not in TEMPLATES:
173
+ return _validation_error(
174
+ field="template",
175
+ action=action,
176
+ message=f"Template must be one of: {', '.join(TEMPLATES)}",
177
+ request_id=request_id,
178
+ remediation=f"Use one of: {', '.join(TEMPLATES)}",
179
+ )
180
+
181
+ category = payload.get("category") or "implementation"
182
+ if not isinstance(category, str):
183
+ return _validation_error(
184
+ field="category",
185
+ action=action,
186
+ message="category must be a string",
187
+ request_id=request_id,
188
+ code=ErrorCode.INVALID_FORMAT,
189
+ )
190
+ category = category.strip() or "implementation"
191
+ if category not in CATEGORIES:
192
+ return _validation_error(
193
+ field="category",
194
+ action=action,
195
+ message=f"Category must be one of: {', '.join(CATEGORIES)}",
196
+ request_id=request_id,
197
+ remediation=f"Use one of: {', '.join(CATEGORIES)}",
198
+ )
199
+
200
+ dry_run = payload.get("dry_run", False)
201
+ if dry_run is not None and not isinstance(dry_run, bool):
202
+ return _validation_error(
203
+ field="dry_run",
204
+ action=action,
205
+ message="dry_run must be a boolean",
206
+ request_id=request_id,
207
+ code=ErrorCode.INVALID_FORMAT,
208
+ )
209
+
210
+ path = payload.get("path")
211
+ if path is not None and not isinstance(path, str):
212
+ return _validation_error(
213
+ field="path",
214
+ action=action,
215
+ message="path must be a string",
216
+ request_id=request_id,
217
+ code=ErrorCode.INVALID_FORMAT,
218
+ )
219
+
220
+ specs_dir = _resolve_specs_dir(config, path)
221
+ if specs_dir is None:
222
+ return _specs_directory_missing_error(request_id)
223
+
224
+ if dry_run:
225
+ return asdict(
226
+ success_response(
227
+ data={
228
+ "name": name.strip(),
229
+ "template": template,
230
+ "category": category,
231
+ "dry_run": True,
232
+ "note": "Dry run - no changes made",
233
+ },
234
+ request_id=request_id,
235
+ )
236
+ )
237
+
238
+ start_time = time.perf_counter()
239
+ audit_log(
240
+ "tool_invocation",
241
+ tool="authoring",
242
+ action="spec_create",
243
+ name=name.strip(),
244
+ template=template,
245
+ category=category,
246
+ )
247
+
248
+ result, error = create_spec(
249
+ name=name.strip(),
250
+ template=template,
251
+ category=category,
252
+ specs_dir=specs_dir,
253
+ )
254
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
255
+ metric_key = _metric_name(action)
256
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
257
+
258
+ if error:
259
+ _metrics.counter(metric_key, labels={"status": "error"})
260
+ lowered = error.lower()
261
+ if "already exists" in lowered:
262
+ return asdict(
263
+ error_response(
264
+ f"A specification with name '{name.strip()}' already exists",
265
+ error_code=ErrorCode.DUPLICATE_ENTRY,
266
+ error_type=ErrorType.CONFLICT,
267
+ remediation="Use a different name or update the existing spec",
268
+ request_id=request_id,
269
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
270
+ )
271
+ )
272
+ return asdict(
273
+ error_response(
274
+ f"Failed to create specification: {error}",
275
+ error_code=ErrorCode.INTERNAL_ERROR,
276
+ error_type=ErrorType.INTERNAL,
277
+ remediation="Check that the specs directory is writable",
278
+ request_id=request_id,
279
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
280
+ )
281
+ )
282
+
283
+ data: Dict[str, Any] = {
284
+ "spec_id": (result or {}).get("spec_id"),
285
+ "spec_path": (result or {}).get("spec_path"),
286
+ "template": template,
287
+ "category": category,
288
+ "name": name.strip(),
289
+ }
290
+ if result and result.get("structure"):
291
+ data["structure"] = result["structure"]
292
+
293
+ _metrics.counter(metric_key, labels={"status": "success"})
294
+ return asdict(
295
+ success_response(
296
+ data=data,
297
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
298
+ request_id=request_id,
299
+ )
300
+ )
301
+
302
+
303
+ def _handle_spec_template(*, config: ServerConfig, **payload: Any) -> dict:
304
+ request_id = _request_id()
305
+ action = "spec-template"
306
+
307
+ template_action = payload.get("template_action")
308
+ if not isinstance(template_action, str) or not template_action.strip():
309
+ return _validation_error(
310
+ field="template_action",
311
+ action=action,
312
+ message="Provide one of: list, show, apply",
313
+ request_id=request_id,
314
+ code=ErrorCode.MISSING_REQUIRED,
315
+ )
316
+ template_action = template_action.strip().lower()
317
+ if template_action not in ("list", "show", "apply"):
318
+ return _validation_error(
319
+ field="template_action",
320
+ action=action,
321
+ message="template_action must be one of: list, show, apply",
322
+ request_id=request_id,
323
+ remediation="Use list, show, or apply",
324
+ )
325
+
326
+ template_name = payload.get("template_name")
327
+ if template_action in ("show", "apply"):
328
+ if not isinstance(template_name, str) or not template_name.strip():
329
+ return _validation_error(
330
+ field="template_name",
331
+ action=action,
332
+ message="Provide a template name",
333
+ request_id=request_id,
334
+ code=ErrorCode.MISSING_REQUIRED,
335
+ )
336
+ template_name = template_name.strip()
337
+ if template_name not in TEMPLATES:
338
+ return asdict(
339
+ error_response(
340
+ f"Template '{template_name}' not found",
341
+ error_code=ErrorCode.NOT_FOUND,
342
+ error_type=ErrorType.NOT_FOUND,
343
+ remediation=f"Use template_action='list' to see available templates. Valid: {', '.join(TEMPLATES)}",
344
+ request_id=request_id,
345
+ )
346
+ )
347
+
348
+ data: Dict[str, Any] = {"action": template_action}
349
+ if template_action == "list":
350
+ data["templates"] = [
351
+ {
352
+ "name": "simple",
353
+ "description": "Minimal spec with 1 phase and basic tasks",
354
+ },
355
+ {
356
+ "name": "medium",
357
+ "description": "Standard spec with 2-3 phases (default)",
358
+ },
359
+ {
360
+ "name": "complex",
361
+ "description": "Multi-phase spec with groups and subtasks",
362
+ },
363
+ {
364
+ "name": "security",
365
+ "description": "Security-focused spec with audit tasks",
366
+ },
367
+ ]
368
+ data["total_count"] = len(data["templates"])
369
+ elif template_action == "show":
370
+ data["template_name"] = template_name
371
+ data["content"] = {
372
+ "name": template_name,
373
+ "description": f"Template structure for '{template_name}' specs",
374
+ "usage": f"Use authoring(action='spec-create', template='{template_name}') to create a spec",
375
+ }
376
+ else:
377
+ data["template_name"] = template_name
378
+ data["generated"] = {
379
+ "template": template_name,
380
+ "message": f"Use authoring(action='spec-create', template='{template_name}') to create a new spec",
381
+ }
382
+ data["instructions"] = (
383
+ f"Call authoring(action='spec-create', name='your-spec-name', template='{template_name}')"
384
+ )
385
+
386
+ return asdict(success_response(data=data, request_id=request_id))
387
+
388
+
389
+ def _handle_spec_update_frontmatter(*, config: ServerConfig, **payload: Any) -> dict:
390
+ request_id = _request_id()
391
+ action = "spec-update-frontmatter"
392
+
393
+ spec_id = payload.get("spec_id")
394
+ if not isinstance(spec_id, str) or not spec_id.strip():
395
+ return _validation_error(
396
+ field="spec_id",
397
+ action=action,
398
+ message="Provide a non-empty spec identifier",
399
+ request_id=request_id,
400
+ code=ErrorCode.MISSING_REQUIRED,
401
+ )
402
+
403
+ key = payload.get("key")
404
+ if not isinstance(key, str) or not key.strip():
405
+ return _validation_error(
406
+ field="key",
407
+ action=action,
408
+ message="Provide a non-empty metadata key",
409
+ request_id=request_id,
410
+ code=ErrorCode.MISSING_REQUIRED,
411
+ )
412
+
413
+ value = payload.get("value")
414
+ if value is None:
415
+ return _validation_error(
416
+ field="value",
417
+ action=action,
418
+ message="Provide a value",
419
+ request_id=request_id,
420
+ code=ErrorCode.MISSING_REQUIRED,
421
+ )
422
+
423
+ dry_run = payload.get("dry_run", False)
424
+ if dry_run is not None and not isinstance(dry_run, bool):
425
+ return _validation_error(
426
+ field="dry_run",
427
+ action=action,
428
+ message="dry_run must be a boolean",
429
+ request_id=request_id,
430
+ code=ErrorCode.INVALID_FORMAT,
431
+ )
432
+
433
+ path = payload.get("path")
434
+ if path is not None and not isinstance(path, str):
435
+ return _validation_error(
436
+ field="path",
437
+ action=action,
438
+ message="path must be a string",
439
+ request_id=request_id,
440
+ code=ErrorCode.INVALID_FORMAT,
441
+ )
442
+
443
+ specs_dir = _resolve_specs_dir(config, path)
444
+ if specs_dir is None:
445
+ return _specs_directory_missing_error(request_id)
446
+
447
+ if dry_run:
448
+ return asdict(
449
+ success_response(
450
+ data={
451
+ "spec_id": spec_id.strip(),
452
+ "key": key.strip(),
453
+ "value": value,
454
+ "dry_run": True,
455
+ "note": "Dry run - no changes made",
456
+ },
457
+ request_id=request_id,
458
+ )
459
+ )
460
+
461
+ start_time = time.perf_counter()
462
+ result, error = update_frontmatter(
463
+ spec_id=spec_id.strip(),
464
+ key=key.strip(),
465
+ value=value,
466
+ specs_dir=specs_dir,
467
+ )
468
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
469
+ metric_key = _metric_name(action)
470
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
471
+
472
+ if error or not result:
473
+ _metrics.counter(metric_key, labels={"status": "error"})
474
+ lowered = (error or "").lower()
475
+ if "not found" in lowered and "spec" in lowered:
476
+ return asdict(
477
+ error_response(
478
+ f"Specification '{spec_id.strip()}' not found",
479
+ error_code=ErrorCode.SPEC_NOT_FOUND,
480
+ error_type=ErrorType.NOT_FOUND,
481
+ remediation='Verify the spec ID exists using spec(action="list")',
482
+ request_id=request_id,
483
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
484
+ )
485
+ )
486
+ if "use dedicated" in lowered:
487
+ return asdict(
488
+ error_response(
489
+ error or "Invalid metadata key",
490
+ error_code=ErrorCode.VALIDATION_ERROR,
491
+ error_type=ErrorType.VALIDATION,
492
+ remediation="Use authoring(action='assumption-add') or authoring(action='revision-add') for list fields",
493
+ request_id=request_id,
494
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
495
+ )
496
+ )
497
+ return asdict(
498
+ error_response(
499
+ error or "Failed to update frontmatter",
500
+ error_code=ErrorCode.VALIDATION_ERROR,
501
+ error_type=ErrorType.VALIDATION,
502
+ remediation="Provide a valid key and value",
503
+ request_id=request_id,
504
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
505
+ )
506
+ )
507
+
508
+ _metrics.counter(metric_key, labels={"status": "success"})
509
+ return asdict(
510
+ success_response(
511
+ data=result,
512
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
513
+ request_id=request_id,
514
+ )
515
+ )
516
+
517
+
518
+ def _handle_phase_add(*, config: ServerConfig, **payload: Any) -> dict:
519
+ request_id = _request_id()
520
+ action = "phase-add"
521
+
522
+ spec_id = payload.get("spec_id")
523
+ if not isinstance(spec_id, str) or not spec_id.strip():
524
+ return _validation_error(
525
+ field="spec_id",
526
+ action=action,
527
+ message="Provide a non-empty spec_id parameter",
528
+ remediation="Pass the spec identifier to authoring",
529
+ request_id=request_id,
530
+ code=ErrorCode.MISSING_REQUIRED,
531
+ )
532
+ spec_id = spec_id.strip()
533
+
534
+ title = payload.get("title")
535
+ if not isinstance(title, str) or not title.strip():
536
+ return _validation_error(
537
+ field="title",
538
+ action=action,
539
+ message="Provide a non-empty phase title",
540
+ remediation="Include a descriptive title for the new phase",
541
+ request_id=request_id,
542
+ code=ErrorCode.MISSING_REQUIRED,
543
+ )
544
+ title = title.strip()
545
+
546
+ description = payload.get("description")
547
+ if description is not None and not isinstance(description, str):
548
+ return _validation_error(
549
+ field="description",
550
+ action=action,
551
+ message="Description must be a string",
552
+ request_id=request_id,
553
+ )
554
+ purpose = payload.get("purpose")
555
+ if purpose is not None and not isinstance(purpose, str):
556
+ return _validation_error(
557
+ field="purpose",
558
+ action=action,
559
+ message="Purpose must be a string",
560
+ request_id=request_id,
561
+ )
562
+
563
+ estimated_hours = payload.get("estimated_hours")
564
+ if estimated_hours is not None:
565
+ if isinstance(estimated_hours, bool) or not isinstance(
566
+ estimated_hours, (int, float)
567
+ ):
568
+ return _validation_error(
569
+ field="estimated_hours",
570
+ action=action,
571
+ message="Provide a numeric value",
572
+ request_id=request_id,
573
+ )
574
+ if estimated_hours < 0:
575
+ return _validation_error(
576
+ field="estimated_hours",
577
+ action=action,
578
+ message="Value must be non-negative",
579
+ remediation="Set hours to zero or greater",
580
+ request_id=request_id,
581
+ )
582
+ estimated_hours = float(estimated_hours)
583
+
584
+ position = payload.get("position")
585
+ if position is not None:
586
+ if isinstance(position, bool) or not isinstance(position, int):
587
+ return _validation_error(
588
+ field="position",
589
+ action=action,
590
+ message="Position must be an integer",
591
+ request_id=request_id,
592
+ )
593
+ if position < 0:
594
+ return _validation_error(
595
+ field="position",
596
+ action=action,
597
+ message="Position must be >= 0",
598
+ request_id=request_id,
599
+ )
600
+
601
+ link_previous = payload.get("link_previous", True)
602
+ if not isinstance(link_previous, bool):
603
+ return _validation_error(
604
+ field="link_previous",
605
+ action=action,
606
+ message="Expected a boolean value",
607
+ request_id=request_id,
608
+ )
609
+
610
+ dry_run = payload.get("dry_run", False)
611
+ if not isinstance(dry_run, bool):
612
+ return _validation_error(
613
+ field="dry_run",
614
+ action=action,
615
+ message="Expected a boolean value",
616
+ request_id=request_id,
617
+ )
618
+
619
+ path = payload.get("path")
620
+ if path is not None and not isinstance(path, str):
621
+ return _validation_error(
622
+ field="path",
623
+ action=action,
624
+ message="Workspace path must be a string",
625
+ request_id=request_id,
626
+ )
627
+
628
+ specs_dir = _resolve_specs_dir(config, path)
629
+ if specs_dir is None:
630
+ return _specs_directory_missing_error(request_id)
631
+
632
+ warnings: List[str] = []
633
+ if _phase_exists(spec_id, specs_dir, title):
634
+ warnings.append(
635
+ f"Phase titled '{title}' already exists; the new phase will still be added"
636
+ )
637
+
638
+ audit_log(
639
+ "tool_invocation",
640
+ tool="authoring",
641
+ action=action,
642
+ spec_id=spec_id,
643
+ title=title,
644
+ dry_run=dry_run,
645
+ link_previous=link_previous,
646
+ )
647
+
648
+ metric_key = _metric_name(action)
649
+
650
+ if dry_run:
651
+ _metrics.counter(metric_key, labels={"status": "success", "dry_run": "true"})
652
+ return asdict(
653
+ success_response(
654
+ data={
655
+ "spec_id": spec_id,
656
+ "phase_id": "(preview)",
657
+ "title": title,
658
+ "dry_run": True,
659
+ "note": "Dry run - no changes made",
660
+ },
661
+ warnings=warnings or None,
662
+ request_id=request_id,
663
+ )
664
+ )
665
+
666
+ start_time = time.perf_counter()
667
+ try:
668
+ result, error = add_phase(
669
+ spec_id=spec_id,
670
+ title=title,
671
+ description=description,
672
+ purpose=purpose,
673
+ estimated_hours=estimated_hours,
674
+ position=position,
675
+ link_previous=link_previous,
676
+ specs_dir=specs_dir,
677
+ )
678
+ except Exception as exc: # pragma: no cover - defensive guard
679
+ logger.exception("Unexpected error adding phase")
680
+ _metrics.counter(metric_key, labels={"status": "error"})
681
+ return asdict(
682
+ error_response(
683
+ sanitize_error_message(exc, context="authoring"),
684
+ error_code=ErrorCode.INTERNAL_ERROR,
685
+ error_type=ErrorType.INTERNAL,
686
+ remediation="Check logs for details",
687
+ request_id=request_id,
688
+ )
689
+ )
690
+
691
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
692
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
693
+
694
+ if error:
695
+ _metrics.counter(metric_key, labels={"status": "error"})
696
+ lowered = error.lower()
697
+ if "specification" in lowered and "not found" in lowered:
698
+ return asdict(
699
+ error_response(
700
+ f"Specification '{spec_id}' not found",
701
+ error_code=ErrorCode.SPEC_NOT_FOUND,
702
+ error_type=ErrorType.NOT_FOUND,
703
+ remediation='Verify the spec ID via spec(action="list")',
704
+ request_id=request_id,
705
+ )
706
+ )
707
+ return asdict(
708
+ error_response(
709
+ f"Failed to add phase: {error}",
710
+ error_code=ErrorCode.INTERNAL_ERROR,
711
+ error_type=ErrorType.INTERNAL,
712
+ remediation="Check input values and retry",
713
+ request_id=request_id,
714
+ )
715
+ )
716
+
717
+ _metrics.counter(metric_key, labels={"status": "success"})
718
+ return asdict(
719
+ success_response(
720
+ data={"spec_id": spec_id, "dry_run": False, **(result or {})},
721
+ warnings=warnings or None,
722
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
723
+ request_id=request_id,
724
+ )
725
+ )
726
+
727
+
728
+ def _handle_phase_remove(*, config: ServerConfig, **payload: Any) -> dict:
729
+ request_id = _request_id()
730
+ action = "phase-remove"
731
+
732
+ spec_id = payload.get("spec_id")
733
+ if not isinstance(spec_id, str) or not spec_id.strip():
734
+ return _validation_error(
735
+ field="spec_id",
736
+ action=action,
737
+ message="Provide a non-empty spec_id parameter",
738
+ request_id=request_id,
739
+ code=ErrorCode.MISSING_REQUIRED,
740
+ )
741
+ spec_id = spec_id.strip()
742
+
743
+ phase_id = payload.get("phase_id")
744
+ if not isinstance(phase_id, str) or not phase_id.strip():
745
+ return _validation_error(
746
+ field="phase_id",
747
+ action=action,
748
+ message="Provide the phase identifier (e.g., phase-1)",
749
+ request_id=request_id,
750
+ code=ErrorCode.MISSING_REQUIRED,
751
+ )
752
+ phase_id = phase_id.strip()
753
+
754
+ force = payload.get("force", False)
755
+ if not isinstance(force, bool):
756
+ return _validation_error(
757
+ field="force",
758
+ action=action,
759
+ message="Expected a boolean value",
760
+ request_id=request_id,
761
+ )
762
+
763
+ dry_run = payload.get("dry_run", False)
764
+ if not isinstance(dry_run, bool):
765
+ return _validation_error(
766
+ field="dry_run",
767
+ action=action,
768
+ message="Expected a boolean value",
769
+ request_id=request_id,
770
+ )
771
+
772
+ path = payload.get("path")
773
+ if path is not None and not isinstance(path, str):
774
+ return _validation_error(
775
+ field="path",
776
+ action=action,
777
+ message="Workspace path must be a string",
778
+ request_id=request_id,
779
+ )
780
+
781
+ specs_dir = _resolve_specs_dir(config, path)
782
+ if specs_dir is None:
783
+ return _specs_directory_missing_error(request_id)
784
+
785
+ audit_log(
786
+ "tool_invocation",
787
+ tool="authoring",
788
+ action=action,
789
+ spec_id=spec_id,
790
+ phase_id=phase_id,
791
+ force=force,
792
+ dry_run=dry_run,
793
+ )
794
+
795
+ metric_key = _metric_name(action)
796
+ if dry_run:
797
+ _metrics.counter(
798
+ metric_key, labels={"status": "success", "force": str(force).lower()}
799
+ )
800
+ return asdict(
801
+ success_response(
802
+ data={
803
+ "spec_id": spec_id,
804
+ "phase_id": phase_id,
805
+ "force": force,
806
+ "dry_run": True,
807
+ "note": "Dry run - no changes made",
808
+ },
809
+ request_id=request_id,
810
+ )
811
+ )
812
+
813
+ start_time = time.perf_counter()
814
+ try:
815
+ result, error = remove_phase(
816
+ spec_id=spec_id,
817
+ phase_id=phase_id,
818
+ force=force,
819
+ specs_dir=specs_dir,
820
+ )
821
+ except Exception as exc: # pragma: no cover - defensive guard
822
+ logger.exception("Unexpected error removing phase")
823
+ _metrics.counter(metric_key, labels={"status": "error"})
824
+ return asdict(
825
+ error_response(
826
+ sanitize_error_message(exc, context="authoring"),
827
+ error_code=ErrorCode.INTERNAL_ERROR,
828
+ error_type=ErrorType.INTERNAL,
829
+ remediation="Check logs for details",
830
+ request_id=request_id,
831
+ )
832
+ )
833
+
834
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
835
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
836
+
837
+ if error:
838
+ _metrics.counter(metric_key, labels={"status": "error"})
839
+ lowered = error.lower()
840
+ if "spec" in lowered and "not found" in lowered:
841
+ return asdict(
842
+ error_response(
843
+ f"Specification '{spec_id}' not found",
844
+ error_code=ErrorCode.SPEC_NOT_FOUND,
845
+ error_type=ErrorType.NOT_FOUND,
846
+ remediation='Verify the spec ID via spec(action="list")',
847
+ request_id=request_id,
848
+ )
849
+ )
850
+ if "phase" in lowered and "not found" in lowered:
851
+ return asdict(
852
+ error_response(
853
+ f"Phase '{phase_id}' not found in spec",
854
+ error_code=ErrorCode.PHASE_NOT_FOUND,
855
+ error_type=ErrorType.NOT_FOUND,
856
+ remediation="Confirm the phase exists in the hierarchy",
857
+ request_id=request_id,
858
+ )
859
+ )
860
+ if "not a phase" in lowered:
861
+ return asdict(
862
+ error_response(
863
+ f"Node '{phase_id}' is not a phase",
864
+ error_code=ErrorCode.VALIDATION_ERROR,
865
+ error_type=ErrorType.VALIDATION,
866
+ remediation="Use task-remove for non-phase nodes",
867
+ request_id=request_id,
868
+ )
869
+ )
870
+ if "non-completed" in lowered or "has" in lowered and "task" in lowered:
871
+ return asdict(
872
+ error_response(
873
+ f"Phase '{phase_id}' has non-completed tasks. Use force=True to remove anyway",
874
+ error_code=ErrorCode.CONFLICT,
875
+ error_type=ErrorType.CONFLICT,
876
+ remediation="Set force=True to remove active phases",
877
+ request_id=request_id,
878
+ )
879
+ )
880
+ return asdict(
881
+ error_response(
882
+ f"Failed to remove phase: {error}",
883
+ error_code=ErrorCode.INTERNAL_ERROR,
884
+ error_type=ErrorType.INTERNAL,
885
+ remediation="Check input values and retry",
886
+ request_id=request_id,
887
+ )
888
+ )
889
+
890
+ _metrics.counter(
891
+ metric_key, labels={"status": "success", "force": str(force).lower()}
892
+ )
893
+ return asdict(
894
+ success_response(
895
+ data={"spec_id": spec_id, "dry_run": False, **(result or {})},
896
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
897
+ request_id=request_id,
898
+ )
899
+ )
900
+
901
+
902
+ def _handle_assumption_add(*, config: ServerConfig, **payload: Any) -> dict:
903
+ request_id = _request_id()
904
+ action = "assumption-add"
905
+
906
+ spec_id = payload.get("spec_id")
907
+ if not isinstance(spec_id, str) or not spec_id.strip():
908
+ return _validation_error(
909
+ field="spec_id",
910
+ action=action,
911
+ message="Provide a non-empty spec_id parameter",
912
+ request_id=request_id,
913
+ code=ErrorCode.MISSING_REQUIRED,
914
+ )
915
+ spec_id = spec_id.strip()
916
+
917
+ text = payload.get("text")
918
+ if not isinstance(text, str) or not text.strip():
919
+ return _validation_error(
920
+ field="text",
921
+ action=action,
922
+ message="Provide the assumption text",
923
+ request_id=request_id,
924
+ code=ErrorCode.MISSING_REQUIRED,
925
+ )
926
+ text = text.strip()
927
+
928
+ assumption_type = payload.get("assumption_type") or "constraint"
929
+ if assumption_type not in ASSUMPTION_TYPES:
930
+ return _validation_error(
931
+ field="assumption_type",
932
+ action=action,
933
+ message=f"Must be one of: {', '.join(ASSUMPTION_TYPES)}",
934
+ request_id=request_id,
935
+ )
936
+
937
+ author = payload.get("author")
938
+ if author is not None and not isinstance(author, str):
939
+ return _validation_error(
940
+ field="author",
941
+ action=action,
942
+ message="Author must be a string",
943
+ request_id=request_id,
944
+ )
945
+
946
+ dry_run = payload.get("dry_run", False)
947
+ if not isinstance(dry_run, bool):
948
+ return _validation_error(
949
+ field="dry_run",
950
+ action=action,
951
+ message="Expected a boolean value",
952
+ request_id=request_id,
953
+ )
954
+
955
+ path = payload.get("path")
956
+ if path is not None and not isinstance(path, str):
957
+ return _validation_error(
958
+ field="path",
959
+ action=action,
960
+ message="Workspace path must be a string",
961
+ request_id=request_id,
962
+ )
963
+
964
+ specs_dir = _resolve_specs_dir(config, path)
965
+ if specs_dir is None:
966
+ return _specs_directory_missing_error(request_id)
967
+
968
+ warnings: List[str] = []
969
+ if _assumption_exists(spec_id, specs_dir, text):
970
+ warnings.append(
971
+ "An assumption with identical text already exists; another entry will be appended"
972
+ )
973
+
974
+ audit_log(
975
+ "tool_invocation",
976
+ tool="authoring",
977
+ action=action,
978
+ spec_id=spec_id,
979
+ assumption_type=assumption_type,
980
+ dry_run=dry_run,
981
+ )
982
+
983
+ metric_key = _metric_name(action)
984
+
985
+ if dry_run:
986
+ _metrics.counter(metric_key, labels={"status": "success", "dry_run": "true"})
987
+ data = {
988
+ "spec_id": spec_id,
989
+ "assumption_id": "(preview)",
990
+ "text": text,
991
+ "type": assumption_type,
992
+ "dry_run": True,
993
+ "note": "Dry run - no changes made",
994
+ }
995
+ if author:
996
+ data["author"] = author
997
+ return asdict(
998
+ success_response(
999
+ data=data,
1000
+ warnings=warnings or None,
1001
+ request_id=request_id,
1002
+ )
1003
+ )
1004
+
1005
+ start_time = time.perf_counter()
1006
+ try:
1007
+ result, error = add_assumption(
1008
+ spec_id=spec_id,
1009
+ text=text,
1010
+ assumption_type=assumption_type,
1011
+ author=author,
1012
+ specs_dir=specs_dir,
1013
+ )
1014
+ except Exception as exc: # pragma: no cover - defensive guard
1015
+ logger.exception("Unexpected error adding assumption")
1016
+ _metrics.counter(metric_key, labels={"status": "error"})
1017
+ return asdict(
1018
+ error_response(
1019
+ sanitize_error_message(exc, context="authoring"),
1020
+ error_code=ErrorCode.INTERNAL_ERROR,
1021
+ error_type=ErrorType.INTERNAL,
1022
+ remediation="Check logs for details",
1023
+ request_id=request_id,
1024
+ )
1025
+ )
1026
+
1027
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
1028
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
1029
+
1030
+ if error:
1031
+ _metrics.counter(metric_key, labels={"status": "error"})
1032
+ if "not found" in error.lower():
1033
+ return asdict(
1034
+ error_response(
1035
+ f"Specification '{spec_id}' not found",
1036
+ error_code=ErrorCode.SPEC_NOT_FOUND,
1037
+ error_type=ErrorType.NOT_FOUND,
1038
+ remediation='Verify the spec ID via spec(action="list")',
1039
+ request_id=request_id,
1040
+ )
1041
+ )
1042
+ return asdict(
1043
+ error_response(
1044
+ f"Failed to add assumption: {error}",
1045
+ error_code=ErrorCode.INTERNAL_ERROR,
1046
+ error_type=ErrorType.INTERNAL,
1047
+ remediation="Check that the spec exists",
1048
+ request_id=request_id,
1049
+ )
1050
+ )
1051
+
1052
+ data = {
1053
+ "spec_id": spec_id,
1054
+ "assumption_id": result.get("assumption_id") if result else None,
1055
+ "text": text,
1056
+ "type": assumption_type,
1057
+ "dry_run": False,
1058
+ }
1059
+ if author:
1060
+ data["author"] = author
1061
+
1062
+ _metrics.counter(metric_key, labels={"status": "success"})
1063
+ return asdict(
1064
+ success_response(
1065
+ data=data,
1066
+ warnings=warnings or None,
1067
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
1068
+ request_id=request_id,
1069
+ )
1070
+ )
1071
+
1072
+
1073
+ def _handle_assumption_list(*, config: ServerConfig, **payload: Any) -> dict:
1074
+ request_id = _request_id()
1075
+ action = "assumption-list"
1076
+
1077
+ spec_id = payload.get("spec_id")
1078
+ if not isinstance(spec_id, str) or not spec_id.strip():
1079
+ return _validation_error(
1080
+ field="spec_id",
1081
+ action=action,
1082
+ message="Provide a non-empty spec_id parameter",
1083
+ request_id=request_id,
1084
+ code=ErrorCode.MISSING_REQUIRED,
1085
+ )
1086
+ spec_id = spec_id.strip()
1087
+
1088
+ assumption_type = payload.get("assumption_type")
1089
+ if assumption_type is not None and assumption_type not in ASSUMPTION_TYPES:
1090
+ return _validation_error(
1091
+ field="assumption_type",
1092
+ action=action,
1093
+ message=f"Must be one of: {', '.join(ASSUMPTION_TYPES)}",
1094
+ request_id=request_id,
1095
+ )
1096
+
1097
+ path = payload.get("path")
1098
+ if path is not None and not isinstance(path, str):
1099
+ return _validation_error(
1100
+ field="path",
1101
+ action=action,
1102
+ message="Workspace path must be a string",
1103
+ request_id=request_id,
1104
+ )
1105
+
1106
+ specs_dir = _resolve_specs_dir(config, path)
1107
+ if specs_dir is None:
1108
+ return _specs_directory_missing_error(request_id)
1109
+
1110
+ audit_log(
1111
+ "tool_invocation",
1112
+ tool="authoring",
1113
+ action=action,
1114
+ spec_id=spec_id,
1115
+ assumption_type=assumption_type,
1116
+ )
1117
+
1118
+ metric_key = _metric_name(action)
1119
+ start_time = time.perf_counter()
1120
+ try:
1121
+ result, error = list_assumptions(
1122
+ spec_id=spec_id,
1123
+ assumption_type=assumption_type,
1124
+ specs_dir=specs_dir,
1125
+ )
1126
+ except Exception as exc: # pragma: no cover - defensive guard
1127
+ logger.exception("Unexpected error listing assumptions")
1128
+ _metrics.counter(metric_key, labels={"status": "error"})
1129
+ return asdict(
1130
+ error_response(
1131
+ sanitize_error_message(exc, context="authoring"),
1132
+ error_code=ErrorCode.INTERNAL_ERROR,
1133
+ error_type=ErrorType.INTERNAL,
1134
+ remediation="Check logs for details",
1135
+ request_id=request_id,
1136
+ )
1137
+ )
1138
+
1139
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
1140
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
1141
+
1142
+ if error:
1143
+ _metrics.counter(metric_key, labels={"status": "error"})
1144
+ if "not found" in error.lower():
1145
+ return asdict(
1146
+ error_response(
1147
+ f"Specification '{spec_id}' not found",
1148
+ error_code=ErrorCode.SPEC_NOT_FOUND,
1149
+ error_type=ErrorType.NOT_FOUND,
1150
+ remediation='Verify the spec ID via spec(action="list")',
1151
+ request_id=request_id,
1152
+ )
1153
+ )
1154
+ return asdict(
1155
+ error_response(
1156
+ f"Failed to list assumptions: {error}",
1157
+ error_code=ErrorCode.INTERNAL_ERROR,
1158
+ error_type=ErrorType.INTERNAL,
1159
+ remediation="Check that the spec exists",
1160
+ request_id=request_id,
1161
+ )
1162
+ )
1163
+
1164
+ warnings: List[str] = []
1165
+ if assumption_type:
1166
+ warnings.append(
1167
+ "assumption_type filter is advisory only; all assumptions are returned"
1168
+ )
1169
+
1170
+ _metrics.counter(metric_key, labels={"status": "success"})
1171
+ return asdict(
1172
+ success_response(
1173
+ data=result or {"spec_id": spec_id, "assumptions": [], "total_count": 0},
1174
+ warnings=warnings or None,
1175
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
1176
+ request_id=request_id,
1177
+ )
1178
+ )
1179
+
1180
+
1181
+ def _handle_revision_add(*, config: ServerConfig, **payload: Any) -> dict:
1182
+ request_id = _request_id()
1183
+ action = "revision-add"
1184
+
1185
+ spec_id = payload.get("spec_id")
1186
+ if not isinstance(spec_id, str) or not spec_id.strip():
1187
+ return _validation_error(
1188
+ field="spec_id",
1189
+ action=action,
1190
+ message="Provide a non-empty spec_id parameter",
1191
+ request_id=request_id,
1192
+ code=ErrorCode.MISSING_REQUIRED,
1193
+ )
1194
+ spec_id = spec_id.strip()
1195
+
1196
+ version = payload.get("version")
1197
+ if not isinstance(version, str) or not version.strip():
1198
+ return _validation_error(
1199
+ field="version",
1200
+ action=action,
1201
+ message="Provide the revision version (e.g., 1.1)",
1202
+ request_id=request_id,
1203
+ code=ErrorCode.MISSING_REQUIRED,
1204
+ )
1205
+ version = version.strip()
1206
+
1207
+ changes = payload.get("changes")
1208
+ if not isinstance(changes, str) or not changes.strip():
1209
+ return _validation_error(
1210
+ field="changes",
1211
+ action=action,
1212
+ message="Provide a summary of changes",
1213
+ request_id=request_id,
1214
+ code=ErrorCode.MISSING_REQUIRED,
1215
+ )
1216
+ changes = changes.strip()
1217
+
1218
+ author = payload.get("author")
1219
+ if author is not None and not isinstance(author, str):
1220
+ return _validation_error(
1221
+ field="author",
1222
+ action=action,
1223
+ message="Author must be a string",
1224
+ request_id=request_id,
1225
+ )
1226
+
1227
+ dry_run = payload.get("dry_run", False)
1228
+ if not isinstance(dry_run, bool):
1229
+ return _validation_error(
1230
+ field="dry_run",
1231
+ action=action,
1232
+ message="Expected a boolean value",
1233
+ request_id=request_id,
1234
+ )
1235
+
1236
+ path = payload.get("path")
1237
+ if path is not None and not isinstance(path, str):
1238
+ return _validation_error(
1239
+ field="path",
1240
+ action=action,
1241
+ message="Workspace path must be a string",
1242
+ request_id=request_id,
1243
+ )
1244
+
1245
+ specs_dir = _resolve_specs_dir(config, path)
1246
+ if specs_dir is None:
1247
+ return _specs_directory_missing_error(request_id)
1248
+
1249
+ audit_log(
1250
+ "tool_invocation",
1251
+ tool="authoring",
1252
+ action=action,
1253
+ spec_id=spec_id,
1254
+ version=version,
1255
+ dry_run=dry_run,
1256
+ )
1257
+
1258
+ metric_key = _metric_name(action)
1259
+ if dry_run:
1260
+ _metrics.counter(metric_key, labels={"status": "success", "dry_run": "true"})
1261
+ data = {
1262
+ "spec_id": spec_id,
1263
+ "version": version,
1264
+ "changes": changes,
1265
+ "dry_run": True,
1266
+ "note": "Dry run - no changes made",
1267
+ }
1268
+ if author:
1269
+ data["author"] = author
1270
+ return asdict(
1271
+ success_response(
1272
+ data=data,
1273
+ request_id=request_id,
1274
+ )
1275
+ )
1276
+
1277
+ start_time = time.perf_counter()
1278
+ try:
1279
+ result, error = add_revision(
1280
+ spec_id=spec_id,
1281
+ version=version,
1282
+ changelog=changes,
1283
+ author=author,
1284
+ specs_dir=specs_dir,
1285
+ )
1286
+ except Exception as exc: # pragma: no cover - defensive guard
1287
+ logger.exception("Unexpected error adding revision")
1288
+ _metrics.counter(metric_key, labels={"status": "error"})
1289
+ return asdict(
1290
+ error_response(
1291
+ sanitize_error_message(exc, context="authoring"),
1292
+ error_code=ErrorCode.INTERNAL_ERROR,
1293
+ error_type=ErrorType.INTERNAL,
1294
+ remediation="Check logs for details",
1295
+ request_id=request_id,
1296
+ )
1297
+ )
1298
+
1299
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
1300
+ _metrics.timer(metric_key + ".duration_ms", elapsed_ms)
1301
+
1302
+ if error:
1303
+ _metrics.counter(metric_key, labels={"status": "error"})
1304
+ if "not found" in error.lower():
1305
+ return asdict(
1306
+ error_response(
1307
+ f"Specification '{spec_id}' not found",
1308
+ error_code=ErrorCode.SPEC_NOT_FOUND,
1309
+ error_type=ErrorType.NOT_FOUND,
1310
+ remediation='Verify the spec ID via spec(action="list")',
1311
+ request_id=request_id,
1312
+ )
1313
+ )
1314
+ return asdict(
1315
+ error_response(
1316
+ f"Failed to add revision: {error}",
1317
+ error_code=ErrorCode.INTERNAL_ERROR,
1318
+ error_type=ErrorType.INTERNAL,
1319
+ remediation="Check that the spec exists",
1320
+ request_id=request_id,
1321
+ )
1322
+ )
1323
+
1324
+ data = {
1325
+ "spec_id": spec_id,
1326
+ "version": version,
1327
+ "changes": changes,
1328
+ "dry_run": False,
1329
+ }
1330
+ if author:
1331
+ data["author"] = author
1332
+ if result and result.get("date"):
1333
+ data["date"] = result["date"]
1334
+
1335
+ _metrics.counter(metric_key, labels={"status": "success"})
1336
+ return asdict(
1337
+ success_response(
1338
+ data=data,
1339
+ telemetry={"duration_ms": round(elapsed_ms, 2)},
1340
+ request_id=request_id,
1341
+ )
1342
+ )
1343
+
1344
+
1345
+ _AUTHORING_ROUTER = ActionRouter(
1346
+ tool_name="authoring",
1347
+ actions=[
1348
+ ActionDefinition(
1349
+ name="spec-create",
1350
+ handler=_handle_spec_create,
1351
+ summary=_ACTION_SUMMARY["spec-create"],
1352
+ aliases=("spec_create",),
1353
+ ),
1354
+ ActionDefinition(
1355
+ name="spec-template",
1356
+ handler=_handle_spec_template,
1357
+ summary=_ACTION_SUMMARY["spec-template"],
1358
+ aliases=("spec_template",),
1359
+ ),
1360
+ ActionDefinition(
1361
+ name="spec-update-frontmatter",
1362
+ handler=_handle_spec_update_frontmatter,
1363
+ summary=_ACTION_SUMMARY["spec-update-frontmatter"],
1364
+ aliases=("spec_update_frontmatter",),
1365
+ ),
1366
+ ActionDefinition(
1367
+ name="phase-add",
1368
+ handler=_handle_phase_add,
1369
+ summary=_ACTION_SUMMARY["phase-add"],
1370
+ aliases=("phase_add",),
1371
+ ),
1372
+ ActionDefinition(
1373
+ name="phase-remove",
1374
+ handler=_handle_phase_remove,
1375
+ summary=_ACTION_SUMMARY["phase-remove"],
1376
+ aliases=("phase_remove",),
1377
+ ),
1378
+ ActionDefinition(
1379
+ name="assumption-add",
1380
+ handler=_handle_assumption_add,
1381
+ summary=_ACTION_SUMMARY["assumption-add"],
1382
+ aliases=("assumption_add",),
1383
+ ),
1384
+ ActionDefinition(
1385
+ name="assumption-list",
1386
+ handler=_handle_assumption_list,
1387
+ summary=_ACTION_SUMMARY["assumption-list"],
1388
+ aliases=("assumption_list",),
1389
+ ),
1390
+ ActionDefinition(
1391
+ name="revision-add",
1392
+ handler=_handle_revision_add,
1393
+ summary=_ACTION_SUMMARY["revision-add"],
1394
+ aliases=("revision_add",),
1395
+ ),
1396
+ ],
1397
+ )
1398
+
1399
+
1400
+ def _dispatch_authoring_action(
1401
+ *, action: str, payload: Dict[str, Any], config: ServerConfig
1402
+ ) -> dict:
1403
+ try:
1404
+ return _AUTHORING_ROUTER.dispatch(action=action, config=config, **payload)
1405
+ except ActionRouterError as exc:
1406
+ request_id = _request_id()
1407
+ allowed = ", ".join(exc.allowed_actions)
1408
+ return asdict(
1409
+ error_response(
1410
+ f"Unsupported authoring action '{action}'. Allowed actions: {allowed}",
1411
+ error_code=ErrorCode.VALIDATION_ERROR,
1412
+ error_type=ErrorType.VALIDATION,
1413
+ remediation=f"Use one of: {allowed}",
1414
+ request_id=request_id,
1415
+ )
1416
+ )
1417
+
1418
+
1419
+ def register_unified_authoring_tool(mcp: FastMCP, config: ServerConfig) -> None:
1420
+ """Register the consolidated authoring tool."""
1421
+
1422
+ @canonical_tool(
1423
+ mcp,
1424
+ canonical_name="authoring",
1425
+ )
1426
+ @mcp_tool(tool_name="authoring", emit_metrics=True, audit=True)
1427
+ def authoring(
1428
+ action: str,
1429
+ spec_id: Optional[str] = None,
1430
+ name: Optional[str] = None,
1431
+ template: Optional[str] = None,
1432
+ category: Optional[str] = None,
1433
+ template_action: Optional[str] = None,
1434
+ template_name: Optional[str] = None,
1435
+ key: Optional[str] = None,
1436
+ value: Optional[str] = None,
1437
+ title: Optional[str] = None,
1438
+ description: Optional[str] = None,
1439
+ purpose: Optional[str] = None,
1440
+ estimated_hours: Optional[float] = None,
1441
+ position: Optional[int] = None,
1442
+ link_previous: bool = True,
1443
+ phase_id: Optional[str] = None,
1444
+ force: bool = False,
1445
+ text: Optional[str] = None,
1446
+ assumption_type: Optional[str] = None,
1447
+ author: Optional[str] = None,
1448
+ version: Optional[str] = None,
1449
+ changes: Optional[str] = None,
1450
+ dry_run: bool = False,
1451
+ path: Optional[str] = None,
1452
+ ) -> dict:
1453
+ """Execute authoring workflows via the action router."""
1454
+
1455
+ payload = {
1456
+ "spec_id": spec_id,
1457
+ "name": name,
1458
+ "template": template,
1459
+ "category": category,
1460
+ "template_action": template_action,
1461
+ "template_name": template_name,
1462
+ "key": key,
1463
+ "value": value,
1464
+ "title": title,
1465
+ "description": description,
1466
+ "purpose": purpose,
1467
+ "estimated_hours": estimated_hours,
1468
+ "position": position,
1469
+ "link_previous": link_previous,
1470
+ "phase_id": phase_id,
1471
+ "force": force,
1472
+ "text": text,
1473
+ "assumption_type": assumption_type,
1474
+ "author": author,
1475
+ "version": version,
1476
+ "changes": changes,
1477
+ "dry_run": dry_run,
1478
+ "path": path,
1479
+ }
1480
+ return _dispatch_authoring_action(action=action, payload=payload, config=config)
1481
+
1482
+ logger.debug("Registered unified authoring tool")
1483
+
1484
+
1485
+ __all__ = [
1486
+ "register_unified_authoring_tool",
1487
+ ]