foundry-mcp 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foundry_mcp/__init__.py +7 -0
- foundry_mcp/cli/__init__.py +80 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +633 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +652 -0
- foundry_mcp/cli/commands/session.py +479 -0
- foundry_mcp/cli/commands/specs.py +856 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +259 -0
- foundry_mcp/cli/flags.py +266 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +850 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1636 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/feature_flags.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/journal.py +694 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1350 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +123 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +317 -0
- foundry_mcp/core/prometheus.py +577 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +546 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
- foundry_mcp/core/prompts/plan_review.py +623 -0
- foundry_mcp/core/providers/__init__.py +225 -0
- foundry_mcp/core/providers/base.py +476 -0
- foundry_mcp/core/providers/claude.py +460 -0
- foundry_mcp/core/providers/codex.py +619 -0
- foundry_mcp/core/providers/cursor_agent.py +642 -0
- foundry_mcp/core/providers/detectors.py +488 -0
- foundry_mcp/core/providers/gemini.py +405 -0
- foundry_mcp/core/providers/opencode.py +616 -0
- foundry_mcp/core/providers/opencode_wrapper.js +302 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +729 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +934 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +1650 -0
- foundry_mcp/core/task.py +1289 -0
- foundry_mcp/core/testing.py +450 -0
- foundry_mcp/core/validation.py +2081 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +234 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +289 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +174 -0
- foundry_mcp/dashboard/views/overview.py +160 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/sdd-spec-schema.json +386 -0
- foundry_mcp/server.py +164 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +71 -0
- foundry_mcp/tools/unified/authoring.py +1487 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +198 -0
- foundry_mcp/tools/unified/environment.py +939 -0
- foundry_mcp/tools/unified/error.py +462 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +632 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +745 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +629 -0
- foundry_mcp/tools/unified/review.py +685 -0
- foundry_mcp/tools/unified/review_helpers.py +299 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +580 -0
- foundry_mcp/tools/unified/spec.py +808 -0
- foundry_mcp/tools/unified/task.py +2202 -0
- foundry_mcp/tools/unified/test.py +370 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.3.3.dist-info/METADATA +337 -0
- foundry_mcp-0.3.3.dist-info/RECORD +135 -0
- foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
- foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
]
|