elspais 0.11.2__py3-none-any.whl → 0.43.5__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 (147) hide show
  1. elspais/__init__.py +1 -10
  2. elspais/{sponsors/__init__.py → associates.py} +102 -56
  3. elspais/cli.py +366 -69
  4. elspais/commands/__init__.py +9 -3
  5. elspais/commands/analyze.py +118 -169
  6. elspais/commands/changed.py +12 -23
  7. elspais/commands/config_cmd.py +10 -13
  8. elspais/commands/edit.py +33 -13
  9. elspais/commands/example_cmd.py +319 -0
  10. elspais/commands/hash_cmd.py +161 -183
  11. elspais/commands/health.py +1177 -0
  12. elspais/commands/index.py +98 -115
  13. elspais/commands/init.py +99 -22
  14. elspais/commands/reformat_cmd.py +41 -433
  15. elspais/commands/rules_cmd.py +2 -2
  16. elspais/commands/trace.py +443 -324
  17. elspais/commands/validate.py +193 -411
  18. elspais/config/__init__.py +799 -5
  19. elspais/{core/content_rules.py → content_rules.py} +20 -2
  20. elspais/docs/cli/assertions.md +67 -0
  21. elspais/docs/cli/commands.md +304 -0
  22. elspais/docs/cli/config.md +262 -0
  23. elspais/docs/cli/format.md +66 -0
  24. elspais/docs/cli/git.md +45 -0
  25. elspais/docs/cli/health.md +190 -0
  26. elspais/docs/cli/hierarchy.md +60 -0
  27. elspais/docs/cli/ignore.md +72 -0
  28. elspais/docs/cli/mcp.md +245 -0
  29. elspais/docs/cli/quickstart.md +58 -0
  30. elspais/docs/cli/traceability.md +89 -0
  31. elspais/docs/cli/validation.md +96 -0
  32. elspais/graph/GraphNode.py +383 -0
  33. elspais/graph/__init__.py +40 -0
  34. elspais/graph/annotators.py +927 -0
  35. elspais/graph/builder.py +1886 -0
  36. elspais/graph/deserializer.py +248 -0
  37. elspais/graph/factory.py +284 -0
  38. elspais/graph/metrics.py +127 -0
  39. elspais/graph/mutations.py +161 -0
  40. elspais/graph/parsers/__init__.py +156 -0
  41. elspais/graph/parsers/code.py +213 -0
  42. elspais/graph/parsers/comments.py +112 -0
  43. elspais/graph/parsers/config_helpers.py +29 -0
  44. elspais/graph/parsers/heredocs.py +225 -0
  45. elspais/graph/parsers/journey.py +131 -0
  46. elspais/graph/parsers/remainder.py +79 -0
  47. elspais/graph/parsers/requirement.py +347 -0
  48. elspais/graph/parsers/results/__init__.py +6 -0
  49. elspais/graph/parsers/results/junit_xml.py +229 -0
  50. elspais/graph/parsers/results/pytest_json.py +313 -0
  51. elspais/graph/parsers/test.py +305 -0
  52. elspais/graph/relations.py +78 -0
  53. elspais/graph/serialize.py +216 -0
  54. elspais/html/__init__.py +8 -0
  55. elspais/html/generator.py +731 -0
  56. elspais/html/templates/trace_view.html.j2 +2151 -0
  57. elspais/mcp/__init__.py +45 -29
  58. elspais/mcp/__main__.py +5 -1
  59. elspais/mcp/file_mutations.py +138 -0
  60. elspais/mcp/server.py +1998 -244
  61. elspais/testing/__init__.py +3 -3
  62. elspais/testing/config.py +3 -0
  63. elspais/testing/mapper.py +1 -1
  64. elspais/testing/scanner.py +301 -12
  65. elspais/utilities/__init__.py +1 -0
  66. elspais/utilities/docs_loader.py +115 -0
  67. elspais/utilities/git.py +607 -0
  68. elspais/{core → utilities}/hasher.py +8 -22
  69. elspais/utilities/md_renderer.py +189 -0
  70. elspais/{core → utilities}/patterns.py +56 -51
  71. elspais/utilities/reference_config.py +626 -0
  72. elspais/validation/__init__.py +19 -0
  73. elspais/validation/format.py +264 -0
  74. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/METADATA +7 -4
  75. elspais-0.43.5.dist-info/RECORD +80 -0
  76. elspais/config/defaults.py +0 -179
  77. elspais/config/loader.py +0 -494
  78. elspais/core/__init__.py +0 -21
  79. elspais/core/git.py +0 -346
  80. elspais/core/models.py +0 -320
  81. elspais/core/parser.py +0 -639
  82. elspais/core/rules.py +0 -509
  83. elspais/mcp/context.py +0 -172
  84. elspais/mcp/serializers.py +0 -112
  85. elspais/reformat/__init__.py +0 -50
  86. elspais/reformat/detector.py +0 -112
  87. elspais/reformat/hierarchy.py +0 -247
  88. elspais/reformat/line_breaks.py +0 -218
  89. elspais/reformat/prompts.py +0 -133
  90. elspais/reformat/transformer.py +0 -266
  91. elspais/trace_view/__init__.py +0 -55
  92. elspais/trace_view/coverage.py +0 -183
  93. elspais/trace_view/generators/__init__.py +0 -12
  94. elspais/trace_view/generators/base.py +0 -334
  95. elspais/trace_view/generators/csv.py +0 -118
  96. elspais/trace_view/generators/markdown.py +0 -170
  97. elspais/trace_view/html/__init__.py +0 -33
  98. elspais/trace_view/html/generator.py +0 -1140
  99. elspais/trace_view/html/templates/base.html +0 -283
  100. elspais/trace_view/html/templates/components/code_viewer_modal.html +0 -14
  101. elspais/trace_view/html/templates/components/file_picker_modal.html +0 -20
  102. elspais/trace_view/html/templates/components/legend_modal.html +0 -69
  103. elspais/trace_view/html/templates/components/review_panel.html +0 -118
  104. elspais/trace_view/html/templates/partials/review/help/help-panel.json +0 -244
  105. elspais/trace_view/html/templates/partials/review/help/onboarding.json +0 -77
  106. elspais/trace_view/html/templates/partials/review/help/tooltips.json +0 -237
  107. elspais/trace_view/html/templates/partials/review/review-comments.js +0 -928
  108. elspais/trace_view/html/templates/partials/review/review-data.js +0 -961
  109. elspais/trace_view/html/templates/partials/review/review-help.js +0 -679
  110. elspais/trace_view/html/templates/partials/review/review-init.js +0 -177
  111. elspais/trace_view/html/templates/partials/review/review-line-numbers.js +0 -429
  112. elspais/trace_view/html/templates/partials/review/review-packages.js +0 -1029
  113. elspais/trace_view/html/templates/partials/review/review-position.js +0 -540
  114. elspais/trace_view/html/templates/partials/review/review-resize.js +0 -115
  115. elspais/trace_view/html/templates/partials/review/review-status.js +0 -659
  116. elspais/trace_view/html/templates/partials/review/review-sync.js +0 -992
  117. elspais/trace_view/html/templates/partials/review-styles.css +0 -2238
  118. elspais/trace_view/html/templates/partials/scripts.js +0 -1741
  119. elspais/trace_view/html/templates/partials/styles.css +0 -1756
  120. elspais/trace_view/models.py +0 -378
  121. elspais/trace_view/review/__init__.py +0 -63
  122. elspais/trace_view/review/branches.py +0 -1142
  123. elspais/trace_view/review/models.py +0 -1200
  124. elspais/trace_view/review/position.py +0 -591
  125. elspais/trace_view/review/server.py +0 -1032
  126. elspais/trace_view/review/status.py +0 -455
  127. elspais/trace_view/review/storage.py +0 -1343
  128. elspais/trace_view/scanning.py +0 -213
  129. elspais/trace_view/specs/README.md +0 -84
  130. elspais/trace_view/specs/tv-d00001-template-architecture.md +0 -36
  131. elspais/trace_view/specs/tv-d00002-css-extraction.md +0 -37
  132. elspais/trace_view/specs/tv-d00003-js-extraction.md +0 -43
  133. elspais/trace_view/specs/tv-d00004-build-embedding.md +0 -40
  134. elspais/trace_view/specs/tv-d00005-test-format.md +0 -78
  135. elspais/trace_view/specs/tv-d00010-review-data-models.md +0 -33
  136. elspais/trace_view/specs/tv-d00011-review-storage.md +0 -33
  137. elspais/trace_view/specs/tv-d00012-position-resolution.md +0 -33
  138. elspais/trace_view/specs/tv-d00013-git-branches.md +0 -31
  139. elspais/trace_view/specs/tv-d00014-review-api-server.md +0 -31
  140. elspais/trace_view/specs/tv-d00015-status-modifier.md +0 -27
  141. elspais/trace_view/specs/tv-d00016-js-integration.md +0 -33
  142. elspais/trace_view/specs/tv-p00001-html-generator.md +0 -33
  143. elspais/trace_view/specs/tv-p00002-review-system.md +0 -29
  144. elspais-0.11.2.dist-info/RECORD +0 -101
  145. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/WHEEL +0 -0
  146. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/entry_points.txt +0 -0
  147. {elspais-0.11.2.dist-info → elspais-0.43.5.dist-info}/licenses/LICENSE +0 -0
elspais/mcp/server.py CHANGED
@@ -1,354 +1,2108 @@
1
+ """elspais.mcp.server - MCP server implementation.
2
+
3
+ Creates and runs the MCP server exposing elspais functionality.
4
+
5
+ This is a pure interface layer - it consumes TraceGraph directly
6
+ without creating intermediate data structures (REQ-p00060-B).
1
7
  """
2
- elspais.mcp.server - MCP server implementation.
3
8
 
4
- Creates and runs the MCP server exposing elspais functionality.
5
- """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ try:
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ MCP_AVAILABLE = True
19
+ except ImportError:
20
+ MCP_AVAILABLE = False
21
+ FastMCP = None
22
+
23
+ from elspais.config import find_config_file, get_config
24
+ from elspais.graph import NodeKind
25
+ from elspais.graph.annotators import count_by_coverage, count_by_git_status, count_by_level
26
+ from elspais.graph.builder import TraceGraph
27
+ from elspais.graph.factory import build_graph
28
+ from elspais.graph.mutations import MutationEntry
29
+ from elspais.graph.relations import EdgeKind
30
+
31
+ # ─────────────────────────────────────────────────────────────────────────────
32
+ # Serializers (REQ-d00064)
33
+ # ─────────────────────────────────────────────────────────────────────────────
34
+
35
+
36
+ def _serialize_requirement_summary(node: Any) -> dict[str, Any]:
37
+ """Serialize a requirement node to summary format.
38
+
39
+ REQ-d00064-A: Returns id, title, level, status only.
40
+ REQ-d00064-C: Reads from node.get_field() and node.get_label().
41
+ """
42
+ return {
43
+ "id": node.id,
44
+ "title": node.get_label(),
45
+ "level": node.get_field("level"),
46
+ "status": node.get_field("status"),
47
+ }
48
+
49
+
50
+ def _serialize_assertion(node: Any) -> dict[str, Any]:
51
+ """Serialize an assertion node."""
52
+ return {
53
+ "id": node.id,
54
+ "label": node.get_field("label"),
55
+ "text": node.get_label(),
56
+ }
57
+
58
+
59
+ def _serialize_requirement_full(node: Any) -> dict[str, Any]:
60
+ """Serialize a requirement node to full format.
61
+
62
+ REQ-d00064-B: Returns all fields including assertions and edges.
63
+ REQ-d00064-C: Reads from node.get_field() and node.get_label().
64
+ """
65
+ # Get assertions from children
66
+ assertions = []
67
+ children = []
68
+ for child in node.iter_children():
69
+ if child.kind == NodeKind.ASSERTION:
70
+ assertions.append(_serialize_assertion(child))
71
+ else:
72
+ children.append(_serialize_requirement_summary(child))
73
+
74
+ # Get parents
75
+ parents = []
76
+ for parent in node.iter_parents():
77
+ if parent.kind == NodeKind.REQUIREMENT:
78
+ parents.append(_serialize_requirement_summary(parent))
79
+
80
+ return {
81
+ "id": node.id,
82
+ "title": node.get_label(),
83
+ "level": node.get_field("level"),
84
+ "status": node.get_field("status"),
85
+ "hash": node.get_field("hash"),
86
+ "assertions": assertions,
87
+ "children": children,
88
+ "parents": parents,
89
+ }
90
+
91
+
92
+ def _serialize_mutation_entry(entry: MutationEntry) -> dict[str, Any]:
93
+ """Serialize a MutationEntry to dict format.
94
+
95
+ REQ-o00062-E: Returns MutationEntry for audit trail.
96
+ """
97
+ return {
98
+ "id": entry.id,
99
+ "operation": entry.operation,
100
+ "target_id": entry.target_id,
101
+ "before_state": entry.before_state,
102
+ "after_state": entry.after_state,
103
+ "affects_hash": entry.affects_hash,
104
+ "timestamp": entry.timestamp.isoformat(),
105
+ }
106
+
107
+
108
+ def _serialize_broken_reference(ref: Any) -> dict[str, Any]:
109
+ """Serialize a BrokenReference to dict format."""
110
+ return {
111
+ "source_id": ref.source_id,
112
+ "target_id": ref.target_id,
113
+ "edge_kind": str(ref.edge_kind) if hasattr(ref.edge_kind, "value") else ref.edge_kind,
114
+ }
115
+
116
+
117
+ # ─────────────────────────────────────────────────────────────────────────────
118
+ # Core Tool Functions (REQ-o00060)
119
+ # ─────────────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ def _get_graph_status(graph: TraceGraph) -> dict[str, Any]:
123
+ """Get graph status.
124
+
125
+ REQ-d00060-A: Returns is_stale from metadata.
126
+ REQ-d00060-B: Returns node_counts by calling nodes_by_kind().
127
+ REQ-d00060-D: Returns root_count using graph.root_count().
128
+ REQ-d00060-E: Does NOT iterate full graph for counts.
129
+ """
130
+ # Count nodes by kind using the efficient nodes_by_kind iterator
131
+ node_counts: dict[str, int] = {}
132
+ for kind in NodeKind:
133
+ count = sum(1 for _ in graph.nodes_by_kind(kind))
134
+ if count > 0:
135
+ node_counts[kind.value] = count
136
+
137
+ return {
138
+ "root_count": graph.root_count(),
139
+ "node_counts": node_counts,
140
+ "total_nodes": graph.node_count(),
141
+ "has_orphans": graph.has_orphans(),
142
+ "has_broken_references": graph.has_broken_references(),
143
+ }
144
+
145
+
146
+ def _refresh_graph(
147
+ repo_root: Path,
148
+ full: bool = False,
149
+ ) -> tuple[dict[str, Any], TraceGraph]:
150
+ """Rebuild the graph from spec files.
151
+
152
+ REQ-o00060-B: Forces graph rebuild.
153
+
154
+ Args:
155
+ repo_root: Repository root path.
156
+ full: If True, clear all caches before rebuild.
157
+
158
+ Returns:
159
+ Tuple of (result dict, new TraceGraph).
160
+ """
161
+ # Build fresh graph
162
+ new_graph = build_graph(repo_root=repo_root)
163
+
164
+ return {
165
+ "success": True,
166
+ "message": "Graph refreshed successfully",
167
+ "node_count": new_graph.node_count(),
168
+ }, new_graph
169
+
170
+
171
+ def _search(
172
+ graph: TraceGraph,
173
+ query: str,
174
+ field: str = "all",
175
+ regex: bool = False,
176
+ limit: int = 50,
177
+ ) -> list[dict[str, Any]]:
178
+ """Search requirements.
179
+
180
+ REQ-d00061-A: Iterates graph.nodes_by_kind(REQUIREMENT).
181
+ REQ-d00061-B: Supports field parameter (id, title, body, keywords, all).
182
+ REQ-d00061-C: Supports regex=True for regex matching.
183
+ REQ-d00061-D: Returns serialized requirement summaries.
184
+ REQ-d00061-E: Limits results to prevent unbounded sizes.
185
+ """
186
+ results = []
187
+
188
+ # Compile pattern if regex mode
189
+ if regex:
190
+ try:
191
+ pattern = re.compile(query, re.IGNORECASE)
192
+ except re.error:
193
+ return []
194
+ else:
195
+ # Simple case-insensitive substring match
196
+ query_lower = query.lower()
197
+
198
+ for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
199
+ match = False
200
+
201
+ if field in ("id", "all"):
202
+ if regex:
203
+ if pattern.search(node.id):
204
+ match = True
205
+ else:
206
+ if query_lower in node.id.lower():
207
+ match = True
208
+
209
+ if not match and field in ("title", "all"):
210
+ title = node.get_label() or ""
211
+ if regex:
212
+ if pattern.search(title):
213
+ match = True
214
+ else:
215
+ if query_lower in title.lower():
216
+ match = True
217
+
218
+ if not match and field in ("keywords", "all"):
219
+ # Search in keywords field
220
+ keywords = node.get_field("keywords", [])
221
+ for keyword in keywords:
222
+ if regex:
223
+ if pattern.search(keyword):
224
+ match = True
225
+ break
226
+ else:
227
+ if query_lower in keyword.lower():
228
+ match = True
229
+ break
230
+
231
+ if match:
232
+ results.append(_serialize_requirement_summary(node))
233
+ if len(results) >= limit:
234
+ break
235
+
236
+ return results
237
+
238
+
239
+ def _get_requirement(graph: TraceGraph, req_id: str) -> dict[str, Any]:
240
+ """Get single requirement details.
241
+
242
+ REQ-d00062-A: Uses graph.find_by_id() for O(1) lookup.
243
+ REQ-d00062-B: Returns node fields.
244
+ REQ-d00062-C: Returns assertions from iter_children().
245
+ REQ-d00062-D: Returns relationships from iter_outgoing_edges().
246
+ REQ-d00062-F: Returns error for non-existent requirements.
247
+ """
248
+ node = graph.find_by_id(req_id)
249
+
250
+ if node is None:
251
+ return {"error": f"Requirement '{req_id}' not found"}
252
+
253
+ if node.kind != NodeKind.REQUIREMENT:
254
+ return {"error": f"Node '{req_id}' is not a requirement"}
255
+
256
+ return _serialize_requirement_full(node)
257
+
258
+
259
+ def _get_hierarchy(graph: TraceGraph, req_id: str) -> dict[str, Any]:
260
+ """Get requirement hierarchy.
261
+
262
+ REQ-d00063-A: Returns ancestors by walking iter_parents() recursively.
263
+ REQ-d00063-B: Returns children from iter_children().
264
+ REQ-d00063-D: Returns node summaries (id, title, level).
265
+ REQ-d00063-E: Handles DAG with multiple parents.
266
+ """
267
+ node = graph.find_by_id(req_id)
268
+
269
+ if node is None:
270
+ return {"error": f"Requirement '{req_id}' not found"}
271
+
272
+ # Collect ancestors recursively (handles DAG)
273
+ ancestors = []
274
+ visited = set()
275
+
276
+ def walk_ancestors(n):
277
+ for parent in n.iter_parents():
278
+ if parent.id not in visited and parent.kind == NodeKind.REQUIREMENT:
279
+ visited.add(parent.id)
280
+ ancestors.append(_serialize_requirement_summary(parent))
281
+ walk_ancestors(parent)
282
+
283
+ walk_ancestors(node)
284
+
285
+ # Collect children (only requirements, not assertions)
286
+ children = []
287
+ for child in node.iter_children():
288
+ if child.kind == NodeKind.REQUIREMENT:
289
+ children.append(_serialize_requirement_summary(child))
290
+
291
+ return {
292
+ "id": req_id,
293
+ "ancestors": ancestors,
294
+ "children": children,
295
+ }
296
+
297
+
298
+ # ─────────────────────────────────────────────────────────────────────────────
299
+ # Workspace Context Tools (REQ-o00061)
300
+ # ─────────────────────────────────────────────────────────────────────────────
301
+
302
+
303
+ def _get_workspace_info(working_dir: Path) -> dict[str, Any]:
304
+ """Get workspace information.
305
+
306
+ REQ-o00061-A: Returns repository path, project name, and configuration summary.
307
+ REQ-o00061-D: Reads configuration from unified config system.
308
+
309
+ Args:
310
+ working_dir: The repository root directory.
311
+
312
+ Returns:
313
+ Workspace information dict.
314
+ """
315
+ config = get_config(start_path=working_dir, quiet=True)
316
+
317
+ # Get project name from config, fallback to directory name
318
+ project_name = config.get("project", {}).get("name")
319
+ if not project_name:
320
+ project_name = working_dir.name
321
+
322
+ # Build configuration summary
323
+ config_summary = {
324
+ "prefix": config.get("patterns", {}).get("prefix", "REQ"),
325
+ "spec_directories": config.get("spec", {}).get("directories", ["spec"]),
326
+ "testing_enabled": config.get("testing", {}).get("enabled", False),
327
+ "project_type": config.get("project", {}).get("type"),
328
+ }
329
+
330
+ # Check if config file exists
331
+ config_file = find_config_file(working_dir)
332
+
333
+ return {
334
+ "repo_path": str(working_dir),
335
+ "project_name": project_name,
336
+ "config_file": str(config_file) if config_file else None,
337
+ "config_summary": config_summary,
338
+ }
339
+
340
+
341
+ def _get_project_summary(graph: TraceGraph, working_dir: Path) -> dict[str, Any]:
342
+ """Get project summary statistics.
343
+
344
+ REQ-o00061-B: Returns requirement counts by level, coverage statistics, and change metrics.
345
+ REQ-o00061-C: Uses graph aggregate functions from annotators module.
346
+
347
+ Args:
348
+ graph: The TraceGraph to analyze.
349
+ working_dir: The repository root directory.
350
+
351
+ Returns:
352
+ Project summary dict.
353
+ """
354
+ # Use aggregate functions from annotators (REQ-o00061-C)
355
+ level_counts = count_by_level(graph)
356
+ coverage_stats = count_by_coverage(graph)
357
+ change_metrics = count_by_git_status(graph)
358
+
359
+ return {
360
+ "requirements_by_level": level_counts,
361
+ "coverage": coverage_stats,
362
+ "changes": change_metrics,
363
+ "total_nodes": graph.node_count(),
364
+ "orphan_count": graph.orphan_count(),
365
+ "broken_reference_count": len(graph.broken_references()),
366
+ }
367
+
368
+
369
+ # ─────────────────────────────────────────────────────────────────────────────
370
+ # Mutation Tool Functions (REQ-o00062)
371
+ # ─────────────────────────────────────────────────────────────────────────────
372
+
373
+
374
+ def _mutate_rename_node(graph: TraceGraph, old_id: str, new_id: str) -> dict[str, Any]:
375
+ """Rename a node.
376
+
377
+ REQ-d00065-A: Delegates to graph.rename_node().
378
+ REQ-o00062-E: Returns MutationEntry for audit.
379
+ """
380
+ try:
381
+ entry = graph.rename_node(old_id, new_id)
382
+ return {
383
+ "success": True,
384
+ "mutation": _serialize_mutation_entry(entry),
385
+ "message": f"Renamed {old_id} to {new_id}",
386
+ }
387
+ except (ValueError, KeyError) as e:
388
+ return {"success": False, "error": str(e)}
389
+
390
+
391
+ def _mutate_update_title(graph: TraceGraph, node_id: str, new_title: str) -> dict[str, Any]:
392
+ """Update requirement title.
393
+
394
+ REQ-d00065-D: Only parameter validation and delegation.
395
+ REQ-o00062-E: Returns MutationEntry for audit.
396
+ """
397
+ try:
398
+ entry = graph.update_title(node_id, new_title)
399
+ return {
400
+ "success": True,
401
+ "mutation": _serialize_mutation_entry(entry),
402
+ "message": f"Updated title of {node_id}",
403
+ }
404
+ except (ValueError, KeyError) as e:
405
+ return {"success": False, "error": str(e)}
406
+
407
+
408
+ def _mutate_change_status(graph: TraceGraph, node_id: str, new_status: str) -> dict[str, Any]:
409
+ """Change requirement status.
410
+
411
+ REQ-d00065-D: Only parameter validation and delegation.
412
+ REQ-o00062-E: Returns MutationEntry for audit.
413
+ """
414
+ try:
415
+ entry = graph.change_status(node_id, new_status)
416
+ return {
417
+ "success": True,
418
+ "mutation": _serialize_mutation_entry(entry),
419
+ "message": f"Changed status of {node_id} to {new_status}",
420
+ }
421
+ except (ValueError, KeyError) as e:
422
+ return {"success": False, "error": str(e)}
423
+
424
+
425
+ def _mutate_add_requirement(
426
+ graph: TraceGraph,
427
+ req_id: str,
428
+ title: str,
429
+ level: str,
430
+ status: str = "Draft",
431
+ parent_id: str | None = None,
432
+ edge_kind: str | None = None,
433
+ ) -> dict[str, Any]:
434
+ """Add a new requirement.
435
+
436
+ REQ-d00065-B: Delegates to graph.add_requirement().
437
+ REQ-o00062-E: Returns MutationEntry for audit.
438
+ """
439
+ try:
440
+ # Convert edge_kind string to EdgeKind enum if provided
441
+ edge_kind_enum = None
442
+ if edge_kind:
443
+ edge_kind_enum = EdgeKind[edge_kind.upper()]
444
+
445
+ entry = graph.add_requirement(
446
+ req_id=req_id,
447
+ title=title,
448
+ level=level,
449
+ status=status,
450
+ parent_id=parent_id,
451
+ edge_kind=edge_kind_enum,
452
+ )
453
+ return {
454
+ "success": True,
455
+ "mutation": _serialize_mutation_entry(entry),
456
+ "message": f"Added requirement {req_id}",
457
+ }
458
+ except (ValueError, KeyError) as e:
459
+ return {"success": False, "error": str(e)}
460
+
461
+
462
+ def _mutate_delete_requirement(
463
+ graph: TraceGraph, node_id: str, confirm: bool = False
464
+ ) -> dict[str, Any]:
465
+ """Delete a requirement.
466
+
467
+ REQ-d00065-C: Calls graph.delete_requirement() only if confirm=True.
468
+ REQ-o00062-F: Requires confirm=True for destructive operations.
469
+ REQ-o00062-E: Returns MutationEntry for audit.
470
+ """
471
+ if not confirm:
472
+ return {
473
+ "success": False,
474
+ "error": "Destructive operation requires confirm=True",
475
+ }
476
+
477
+ try:
478
+ entry = graph.delete_requirement(node_id)
479
+ return {
480
+ "success": True,
481
+ "mutation": _serialize_mutation_entry(entry),
482
+ "message": f"Deleted requirement {node_id}",
483
+ }
484
+ except (ValueError, KeyError) as e:
485
+ return {"success": False, "error": str(e)}
486
+
487
+
488
+ # ─────────────────────────────────────────────────────────────────────────────
489
+ # Assertion Mutation Functions (REQ-o00062-B)
490
+ # ─────────────────────────────────────────────────────────────────────────────
491
+
492
+
493
+ def _mutate_add_assertion(graph: TraceGraph, req_id: str, label: str, text: str) -> dict[str, Any]:
494
+ """Add assertion to requirement.
495
+
496
+ REQ-d00065-D: Only parameter validation and delegation.
497
+ REQ-o00062-E: Returns MutationEntry for audit.
498
+ """
499
+ try:
500
+ entry = graph.add_assertion(req_id, label, text)
501
+ return {
502
+ "success": True,
503
+ "mutation": _serialize_mutation_entry(entry),
504
+ "message": f"Added assertion {req_id}-{label}",
505
+ }
506
+ except (ValueError, KeyError) as e:
507
+ return {"success": False, "error": str(e)}
508
+
509
+
510
+ def _mutate_update_assertion(graph: TraceGraph, assertion_id: str, new_text: str) -> dict[str, Any]:
511
+ """Update assertion text.
512
+
513
+ REQ-d00065-D: Only parameter validation and delegation.
514
+ REQ-o00062-E: Returns MutationEntry for audit.
515
+ """
516
+ try:
517
+ entry = graph.update_assertion(assertion_id, new_text)
518
+ return {
519
+ "success": True,
520
+ "mutation": _serialize_mutation_entry(entry),
521
+ "message": f"Updated assertion {assertion_id}",
522
+ }
523
+ except (ValueError, KeyError) as e:
524
+ return {"success": False, "error": str(e)}
525
+
526
+
527
+ def _mutate_delete_assertion(
528
+ graph: TraceGraph,
529
+ assertion_id: str,
530
+ compact: bool = True,
531
+ confirm: bool = False,
532
+ ) -> dict[str, Any]:
533
+ """Delete assertion.
534
+
535
+ REQ-o00062-F: Requires confirm=True for destructive operations.
536
+ REQ-o00062-E: Returns MutationEntry for audit.
537
+ """
538
+ if not confirm:
539
+ return {
540
+ "success": False,
541
+ "error": "Destructive operation requires confirm=True",
542
+ }
543
+
544
+ try:
545
+ entry = graph.delete_assertion(assertion_id, compact=compact)
546
+ return {
547
+ "success": True,
548
+ "mutation": _serialize_mutation_entry(entry),
549
+ "message": f"Deleted assertion {assertion_id}",
550
+ }
551
+ except (ValueError, KeyError) as e:
552
+ return {"success": False, "error": str(e)}
553
+
554
+
555
+ def _mutate_rename_assertion(graph: TraceGraph, old_id: str, new_label: str) -> dict[str, Any]:
556
+ """Rename assertion label.
557
+
558
+ REQ-d00065-D: Only parameter validation and delegation.
559
+ REQ-o00062-E: Returns MutationEntry for audit.
560
+ """
561
+ try:
562
+ entry = graph.rename_assertion(old_id, new_label)
563
+ return {
564
+ "success": True,
565
+ "mutation": _serialize_mutation_entry(entry),
566
+ "message": f"Renamed assertion {old_id} to new label {new_label}",
567
+ }
568
+ except (ValueError, KeyError) as e:
569
+ return {"success": False, "error": str(e)}
570
+
571
+
572
+ # ─────────────────────────────────────────────────────────────────────────────
573
+ # Edge Mutation Functions (REQ-o00062-C)
574
+ # ─────────────────────────────────────────────────────────────────────────────
575
+
576
+
577
+ def _mutate_add_edge(
578
+ graph: TraceGraph,
579
+ source_id: str,
580
+ target_id: str,
581
+ edge_kind: str,
582
+ assertion_targets: list[str] | None = None,
583
+ ) -> dict[str, Any]:
584
+ """Add an edge between nodes.
585
+
586
+ REQ-d00065-D: Only parameter validation and delegation.
587
+ REQ-o00062-E: Returns MutationEntry for audit.
588
+ """
589
+ try:
590
+ edge_kind_enum = EdgeKind[edge_kind.upper()]
591
+ entry = graph.add_edge(
592
+ source_id=source_id,
593
+ target_id=target_id,
594
+ edge_kind=edge_kind_enum,
595
+ assertion_targets=assertion_targets,
596
+ )
597
+ return {
598
+ "success": True,
599
+ "mutation": _serialize_mutation_entry(entry),
600
+ "message": f"Added edge {source_id} --[{edge_kind}]--> {target_id}",
601
+ }
602
+ except (ValueError, KeyError) as e:
603
+ return {"success": False, "error": str(e)}
604
+
605
+
606
+ def _mutate_change_edge_kind(
607
+ graph: TraceGraph,
608
+ source_id: str,
609
+ target_id: str,
610
+ new_kind: str,
611
+ ) -> dict[str, Any]:
612
+ """Change edge type.
613
+
614
+ REQ-d00065-D: Only parameter validation and delegation.
615
+ REQ-o00062-E: Returns MutationEntry for audit.
616
+ """
617
+ try:
618
+ new_kind_enum = EdgeKind[new_kind.upper()]
619
+ entry = graph.change_edge_kind(source_id, target_id, new_kind_enum)
620
+ return {
621
+ "success": True,
622
+ "mutation": _serialize_mutation_entry(entry),
623
+ "message": f"Changed edge {source_id} -> {target_id} to {new_kind}",
624
+ }
625
+ except (ValueError, KeyError) as e:
626
+ return {"success": False, "error": str(e)}
627
+
628
+
629
+ def _mutate_delete_edge(
630
+ graph: TraceGraph,
631
+ source_id: str,
632
+ target_id: str,
633
+ confirm: bool = False,
634
+ ) -> dict[str, Any]:
635
+ """Delete an edge.
636
+
637
+ REQ-o00062-F: Requires confirm=True for destructive operations.
638
+ REQ-o00062-E: Returns MutationEntry for audit.
639
+ """
640
+ if not confirm:
641
+ return {
642
+ "success": False,
643
+ "error": "Destructive operation requires confirm=True",
644
+ }
645
+
646
+ try:
647
+ entry = graph.delete_edge(source_id, target_id)
648
+ return {
649
+ "success": True,
650
+ "mutation": _serialize_mutation_entry(entry),
651
+ "message": f"Deleted edge {source_id} -> {target_id}",
652
+ }
653
+ except (ValueError, KeyError) as e:
654
+ return {"success": False, "error": str(e)}
655
+
656
+
657
+ def _mutate_fix_broken_reference(
658
+ graph: TraceGraph,
659
+ source_id: str,
660
+ old_target_id: str,
661
+ new_target_id: str,
662
+ ) -> dict[str, Any]:
663
+ """Fix a broken reference by redirecting to a valid target.
664
+
665
+ REQ-d00065-D: Only parameter validation and delegation.
666
+ REQ-o00062-E: Returns MutationEntry for audit.
667
+ """
668
+ try:
669
+ entry = graph.fix_broken_reference(source_id, old_target_id, new_target_id)
670
+ return {
671
+ "success": True,
672
+ "mutation": _serialize_mutation_entry(entry),
673
+ "message": f"Fixed reference {source_id}: {old_target_id} -> {new_target_id}",
674
+ }
675
+ except (ValueError, KeyError) as e:
676
+ return {"success": False, "error": str(e)}
677
+
678
+
679
+ # ─────────────────────────────────────────────────────────────────────────────
680
+ # Undo Operations (REQ-o00062-G)
681
+ # ─────────────────────────────────────────────────────────────────────────────
682
+
683
+
684
+ def _undo_last_mutation(graph: TraceGraph) -> dict[str, Any]:
685
+ """Undo the most recent mutation.
686
+
687
+ REQ-o00062-G: Reverses mutations using graph.undo_last().
688
+ """
689
+ entry = graph.undo_last()
690
+ if entry is None:
691
+ return {"success": False, "error": "No mutations to undo"}
692
+
693
+ return {
694
+ "success": True,
695
+ "mutation": _serialize_mutation_entry(entry),
696
+ "message": f"Undid {entry.operation} on {entry.target_id}",
697
+ }
698
+
699
+
700
+ def _undo_to_mutation(graph: TraceGraph, mutation_id: str) -> dict[str, Any]:
701
+ """Undo all mutations back to a specific point.
702
+
703
+ REQ-o00062-G: Reverses mutations using graph.undo_to().
704
+ """
705
+ try:
706
+ entries = graph.undo_to(mutation_id)
707
+ return {
708
+ "success": True,
709
+ "mutations_undone": len(entries),
710
+ "mutations": [_serialize_mutation_entry(e) for e in entries],
711
+ "message": f"Undid {len(entries)} mutations",
712
+ }
713
+ except (ValueError, KeyError) as e:
714
+ return {"success": False, "error": str(e)}
715
+
716
+
717
+ def _get_mutation_log(graph: TraceGraph, limit: int = 50) -> dict[str, Any]:
718
+ """Get mutation history.
719
+
720
+ Returns the most recent mutations from the log.
721
+ """
722
+ mutations = []
723
+ for entry in graph.mutation_log.iter_entries():
724
+ mutations.append(_serialize_mutation_entry(entry))
725
+ if len(mutations) >= limit:
726
+ break
727
+
728
+ return {
729
+ "mutations": mutations,
730
+ "count": len(mutations),
731
+ }
732
+
733
+
734
+ # ─────────────────────────────────────────────────────────────────────────────
735
+ # Inspection Functions
736
+ # ─────────────────────────────────────────────────────────────────────────────
737
+
738
+
739
+ def _get_orphaned_nodes(graph: TraceGraph) -> dict[str, Any]:
740
+ """Get all orphaned nodes (nodes with no parents).
741
+
742
+ Returns nodes that have been orphaned due to edge deletions.
743
+ """
744
+ orphans = []
745
+ for node in graph.orphaned_nodes():
746
+ if node.kind == NodeKind.REQUIREMENT:
747
+ orphans.append(_serialize_requirement_summary(node))
748
+
749
+ return {
750
+ "orphans": orphans,
751
+ "count": len(orphans),
752
+ }
753
+
754
+
755
+ def _get_broken_references(graph: TraceGraph) -> dict[str, Any]:
756
+ """Get all broken references.
757
+
758
+ Returns edges that point to non-existent nodes.
759
+ """
760
+ refs = [_serialize_broken_reference(ref) for ref in graph.broken_references()]
761
+
762
+ return {
763
+ "broken_references": refs,
764
+ "count": len(refs),
765
+ }
766
+
767
+
768
+ # ─────────────────────────────────────────────────────────────────────────────
769
+ # Keyword Search Tools (Phase 4)
770
+ # ─────────────────────────────────────────────────────────────────────────────
771
+
772
+
773
+ def _find_by_keywords(
774
+ graph: TraceGraph,
775
+ keywords: list[str],
776
+ match_all: bool = True,
777
+ ) -> dict[str, Any]:
778
+ """Find requirements containing specified keywords.
779
+
780
+ Args:
781
+ graph: The TraceGraph to search.
782
+ keywords: List of keywords to search for.
783
+ match_all: If True, node must contain ALL keywords (AND).
784
+ If False, node must contain ANY keyword (OR).
785
+
786
+ Returns:
787
+ Dict with 'success', 'results', and 'count'.
788
+ """
789
+ from elspais.graph.annotators import find_by_keywords
790
+
791
+ nodes = find_by_keywords(graph, keywords, match_all)
792
+ results = [_serialize_requirement_summary(node) for node in nodes]
793
+
794
+ return {
795
+ "success": True,
796
+ "results": results,
797
+ "count": len(results),
798
+ }
799
+
800
+
801
+ def _get_all_keywords(graph: TraceGraph) -> dict[str, Any]:
802
+ """Get all unique keywords from the graph.
803
+
804
+ Args:
805
+ graph: The TraceGraph to scan.
806
+
807
+ Returns:
808
+ Dict with 'success', 'keywords', and 'count'.
809
+ """
810
+ from elspais.graph.annotators import collect_all_keywords
811
+
812
+ keywords = collect_all_keywords(graph)
813
+
814
+ return {
815
+ "success": True,
816
+ "keywords": keywords,
817
+ "count": len(keywords),
818
+ }
819
+
820
+
821
+ # ─────────────────────────────────────────────────────────────────────────────
822
+ # Test Coverage Tools (REQ-o00064)
823
+ # ─────────────────────────────────────────────────────────────────────────────
824
+
825
+
826
+ def _get_test_coverage(graph: TraceGraph, req_id: str) -> dict[str, Any]:
827
+ """Get test coverage information for a requirement.
828
+
829
+ REQ-d00066-A: SHALL accept req_id parameter identifying the target requirement.
830
+ REQ-d00066-B: SHALL return TEST nodes by finding edges targeting the requirement.
831
+ REQ-d00066-C: SHALL return TEST_RESULT nodes linked to each TEST node.
832
+ REQ-d00066-D: SHALL identify covered assertions via edge assertion_targets.
833
+ REQ-d00066-E: SHALL return uncovered assertions as those with no incoming TEST edges.
834
+ REQ-d00066-F: SHALL return coverage summary with percentage.
835
+ REQ-d00066-G: SHALL use iterator-only API, not traverse full graph.
836
+
837
+ Args:
838
+ graph: The TraceGraph to query.
839
+ req_id: The requirement ID to get coverage for.
840
+
841
+ Returns:
842
+ Dict with success, test_nodes, result_nodes, covered/uncovered assertions,
843
+ and coverage statistics.
844
+ """
845
+ # REQ-d00066-A: Get requirement by ID
846
+ node = graph.find_by_id(req_id)
847
+ if node is None:
848
+ return {"success": False, "error": f"Requirement {req_id} not found"}
849
+
850
+ if node.kind != NodeKind.REQUIREMENT:
851
+ return {"success": False, "error": f"{req_id} is not a requirement"}
852
+
853
+ # Collect assertions from children
854
+ assertions: list[tuple[str, str]] = [] # [(assertion_id, label), ...]
855
+
856
+ for child in node.iter_children():
857
+ if child.kind == NodeKind.ASSERTION:
858
+ label = child.get_field("label", "")
859
+ assertions.append((child.id, label))
860
+
861
+ assertion_ids = [a[0] for a in assertions]
862
+
863
+ # Track TEST nodes and covered assertions
864
+ test_nodes: list[dict[str, Any]] = []
865
+ result_nodes: list[dict[str, Any]] = []
866
+ covered_assertion_ids: set[str] = set()
867
+ seen_test_ids: set[str] = set() # Deduplicate tests
868
+
869
+ # REQ-d00066-B: Find TEST nodes via two patterns:
870
+ # 1. Edges from requirement with assertion_targets (real graph pattern)
871
+ # 2. Edges from assertions to TEST nodes (test fixture pattern)
872
+
873
+ # Pattern 1: Edges from requirement (e.g., annotate_coverage pattern)
874
+ for edge in node.iter_outgoing_edges():
875
+ target = edge.target
876
+ if target.kind != NodeKind.TEST:
877
+ continue
878
+
879
+ if target.id not in seen_test_ids:
880
+ seen_test_ids.add(target.id)
881
+ test_nodes.append(
882
+ {
883
+ "id": target.id,
884
+ "label": target.get_label(),
885
+ "file": target.get_field("file", ""),
886
+ "name": target.get_field("name", ""),
887
+ }
888
+ )
889
+
890
+ # REQ-d00066-C: Get TEST_RESULT children
891
+ for child in target.iter_children():
892
+ if child.kind == NodeKind.TEST_RESULT:
893
+ result_nodes.append(
894
+ {
895
+ "id": child.id,
896
+ "status": child.get_field("status", "unknown"),
897
+ "duration": child.get_field("duration", 0.0),
898
+ "test_id": target.id,
899
+ }
900
+ )
901
+
902
+ # REQ-d00066-D: Track which assertions this test covers
903
+ if edge.assertion_targets:
904
+ for label in edge.assertion_targets:
905
+ # Find assertion ID by label
906
+ for aid, alabel in assertions:
907
+ if alabel == label:
908
+ covered_assertion_ids.add(aid)
909
+ break
910
+
911
+ # Pattern 2: Edges from assertions (test fixture pattern)
912
+ for assertion_id, _label in assertions:
913
+ assertion_node = graph.find_by_id(assertion_id)
914
+ if assertion_node is None:
915
+ continue
916
+
917
+ for edge in assertion_node.iter_outgoing_edges():
918
+ target = edge.target
919
+ if target.kind != NodeKind.TEST:
920
+ continue
921
+
922
+ # This assertion is covered by this test
923
+ covered_assertion_ids.add(assertion_id)
924
+
925
+ if target.id not in seen_test_ids:
926
+ seen_test_ids.add(target.id)
927
+ test_nodes.append(
928
+ {
929
+ "id": target.id,
930
+ "label": target.get_label(),
931
+ "file": target.get_field("file", ""),
932
+ "name": target.get_field("name", ""),
933
+ }
934
+ )
935
+
936
+ # REQ-d00066-C: Get TEST_RESULT children
937
+ for child in target.iter_children():
938
+ if child.kind == NodeKind.TEST_RESULT:
939
+ result_nodes.append(
940
+ {
941
+ "id": child.id,
942
+ "status": child.get_field("status", "unknown"),
943
+ "duration": child.get_field("duration", 0.0),
944
+ "test_id": target.id,
945
+ }
946
+ )
947
+
948
+ # REQ-d00066-E: Determine uncovered assertions
949
+ covered_assertions = sorted(covered_assertion_ids)
950
+ uncovered_assertions = sorted(set(assertion_ids) - covered_assertion_ids)
951
+
952
+ # REQ-d00066-F: Calculate coverage percentage
953
+ total = len(assertion_ids)
954
+ covered_count = len(covered_assertions)
955
+ coverage_pct = (covered_count / total * 100) if total > 0 else 0.0
956
+
957
+ return {
958
+ "success": True,
959
+ "req_id": req_id,
960
+ "test_nodes": test_nodes,
961
+ "result_nodes": result_nodes,
962
+ "covered_assertions": covered_assertions,
963
+ "uncovered_assertions": uncovered_assertions,
964
+ "total_assertions": total,
965
+ "covered_count": covered_count,
966
+ "coverage_pct": round(coverage_pct, 1),
967
+ }
968
+
6
969
 
7
- from pathlib import Path
8
- from typing import Any, Dict, List, Optional
970
+ def _get_uncovered_assertions(
971
+ graph: TraceGraph,
972
+ req_id: str | None = None,
973
+ limit: int = 100,
974
+ ) -> dict[str, Any]:
975
+ """Find assertions lacking test coverage.
9
976
 
10
- try:
11
- from mcp.server.fastmcp import FastMCP
977
+ REQ-d00067-A: SHALL accept optional req_id parameter; when None, scan all requirements.
978
+ REQ-d00067-B: SHALL iterate assertions using nodes_by_kind(ASSERTION).
979
+ REQ-d00067-C: SHALL check each assertion for incoming edges from TEST nodes.
980
+ REQ-d00067-D: SHALL return assertion details: id, text, label, parent requirement context.
981
+ REQ-d00067-E: SHALL return parent requirement id and title for context.
982
+ REQ-d00067-F: SHALL limit results to prevent unbounded response sizes.
12
983
 
13
- MCP_AVAILABLE = True
14
- except ImportError:
15
- MCP_AVAILABLE = False
16
- FastMCP = None
984
+ Args:
985
+ graph: The TraceGraph to query.
986
+ req_id: Optional requirement ID to filter by. If None, scan all requirements.
987
+ limit: Maximum number of results to return.
988
+
989
+ Returns:
990
+ Dict with success and list of uncovered assertions with parent context.
991
+ """
992
+
993
+ def _is_assertion_covered(assertion_node: Any) -> bool:
994
+ """Check if an assertion has any TEST coverage."""
995
+ # Check outgoing edges from assertion (test fixture pattern)
996
+ for edge in assertion_node.iter_outgoing_edges():
997
+ if edge.target.kind == NodeKind.TEST:
998
+ return True
999
+
1000
+ # Check if parent requirement has edges to TEST with this assertion as target
1001
+ for parent in assertion_node.iter_parents():
1002
+ if parent.kind != NodeKind.REQUIREMENT:
1003
+ continue
1004
+ label = assertion_node.get_field("label", "")
1005
+ for edge in parent.iter_outgoing_edges():
1006
+ if edge.target.kind != NodeKind.TEST:
1007
+ continue
1008
+ if edge.assertion_targets and label in edge.assertion_targets:
1009
+ return True
1010
+
1011
+ return False
1012
+
1013
+ uncovered: list[dict[str, Any]] = []
1014
+
1015
+ if req_id is not None:
1016
+ # REQ-d00067-B: Filter to specific requirement's assertions
1017
+ node = graph.find_by_id(req_id)
1018
+ if node is None:
1019
+ return {"success": False, "error": f"Requirement {req_id} not found"}
1020
+
1021
+ if node.kind != NodeKind.REQUIREMENT:
1022
+ return {"success": False, "error": f"{req_id} is not a requirement"}
1023
+
1024
+ for child in node.iter_children():
1025
+ if child.kind != NodeKind.ASSERTION:
1026
+ continue
1027
+ if _is_assertion_covered(child):
1028
+ continue
1029
+
1030
+ # REQ-d00067-D, REQ-d00067-E: Include assertion and parent context
1031
+ uncovered.append(
1032
+ {
1033
+ "id": child.id,
1034
+ "label": child.get_field("label", ""),
1035
+ "text": child.get_label(),
1036
+ "parent_id": req_id,
1037
+ "parent_title": node.get_label(),
1038
+ }
1039
+ )
1040
+
1041
+ if len(uncovered) >= limit:
1042
+ break
1043
+ else:
1044
+ # REQ-d00067-A: Scan all assertions
1045
+ # Group by parent requirement for sorted output
1046
+ req_assertions: dict[str, list[Any]] = {}
1047
+
1048
+ for node in graph.nodes_by_kind(NodeKind.ASSERTION):
1049
+ if _is_assertion_covered(node):
1050
+ continue
1051
+
1052
+ # Find parent requirement
1053
+ parent_req = None
1054
+ for parent in node.iter_parents():
1055
+ if parent.kind == NodeKind.REQUIREMENT:
1056
+ parent_req = parent
1057
+ break
1058
+
1059
+ if parent_req is None:
1060
+ continue
1061
+
1062
+ if parent_req.id not in req_assertions:
1063
+ req_assertions[parent_req.id] = []
1064
+ req_assertions[parent_req.id].append((node, parent_req))
1065
+
1066
+ # Sort by requirement ID and build output
1067
+ for req_id_key in sorted(req_assertions.keys()):
1068
+ for assertion_node, parent_req in req_assertions[req_id_key]:
1069
+ uncovered.append(
1070
+ {
1071
+ "id": assertion_node.id,
1072
+ "label": assertion_node.get_field("label", ""),
1073
+ "text": assertion_node.get_label(),
1074
+ "parent_id": parent_req.id,
1075
+ "parent_title": parent_req.get_label(),
1076
+ }
1077
+ )
1078
+
1079
+ if len(uncovered) >= limit:
1080
+ break
1081
+
1082
+ if len(uncovered) >= limit:
1083
+ break
1084
+
1085
+ return {
1086
+ "success": True,
1087
+ "assertions": uncovered,
1088
+ "count": len(uncovered),
1089
+ }
1090
+
1091
+
1092
+ def _find_assertions_by_keywords(
1093
+ graph: TraceGraph,
1094
+ keywords: list[str],
1095
+ match_all: bool = True,
1096
+ ) -> dict[str, Any]:
1097
+ """Find assertions containing specified keywords.
1098
+
1099
+ REQ-d00068-A: SHALL accept keywords list parameter with search terms.
1100
+ REQ-d00068-B: SHALL accept match_all boolean; True requires all keywords.
1101
+ REQ-d00068-C: SHALL search assertion text (SHALL statement content).
1102
+ REQ-d00068-D: SHALL return assertion id, text, label, and parent context.
1103
+ REQ-d00068-E: SHALL perform case-insensitive matching by default.
1104
+ REQ-d00068-F: SHALL complement find_by_keywords() which searches requirements.
1105
+
1106
+ Args:
1107
+ graph: The TraceGraph to query.
1108
+ keywords: List of keywords to search for.
1109
+ match_all: If True, assertion must contain ALL keywords (AND).
1110
+ If False, assertion must contain ANY keyword (OR).
1111
+
1112
+ Returns:
1113
+ Dict with success and list of matching assertions with parent context.
1114
+ """
1115
+ from elspais.graph.annotators import find_by_keywords
1116
+
1117
+ # REQ-d00068-C: Use graph API to search assertion nodes by keyword
1118
+ # REQ-d00068-E: find_by_keywords handles case normalization
1119
+ nodes = find_by_keywords(graph, keywords, match_all, kind=NodeKind.ASSERTION)
1120
+
1121
+ # REQ-d00068-D: Format results with parent context
1122
+ results: list[dict[str, Any]] = []
1123
+ for node in nodes:
1124
+ # Find parent requirement
1125
+ parent_req = None
1126
+ for parent in node.iter_parents():
1127
+ if parent.kind == NodeKind.REQUIREMENT:
1128
+ parent_req = parent
1129
+ break
1130
+
1131
+ results.append(
1132
+ {
1133
+ "id": node.id,
1134
+ "label": node.get_field("label", ""),
1135
+ "text": node.get_label() or "",
1136
+ "parent_id": parent_req.id if parent_req else None,
1137
+ "parent_title": parent_req.get_label() if parent_req else None,
1138
+ }
1139
+ )
1140
+
1141
+ return {
1142
+ "success": True,
1143
+ "assertions": results,
1144
+ "count": len(results),
1145
+ }
1146
+
1147
+
1148
+ # ─────────────────────────────────────────────────────────────────────────────
1149
+ # File Mutation Tools (REQ-o00063)
1150
+ # ─────────────────────────────────────────────────────────────────────────────
1151
+
1152
+
1153
+ def _change_reference_type(
1154
+ repo_root: Path,
1155
+ req_id: str,
1156
+ target_id: str,
1157
+ new_type: str,
1158
+ save_branch: bool = False,
1159
+ ) -> dict[str, Any]:
1160
+ """Change a reference type in a spec file (Implements -> Refines or vice versa).
1161
+
1162
+ REQ-o00063-A: Modify Implements/Refines relationships in spec files.
1163
+
1164
+ Args:
1165
+ repo_root: Repository root path.
1166
+ req_id: ID of the requirement to modify.
1167
+ target_id: ID of the target requirement being referenced.
1168
+ new_type: New reference type ('IMPLEMENTS' or 'REFINES').
1169
+ save_branch: If True, create a safety branch before modifying.
1170
+
1171
+ Returns:
1172
+ Success status and optional safety_branch name.
1173
+ """
1174
+ from elspais.utilities.git import create_safety_branch
1175
+
1176
+ # Normalize type
1177
+ new_type_lower = new_type.lower()
1178
+ if new_type_lower not in ("implements", "refines"):
1179
+ return {"success": False, "error": f"Invalid reference type: {new_type}"}
1180
+
1181
+ # Find the spec file containing req_id
1182
+ spec_dir = repo_root / "spec"
1183
+ if not spec_dir.exists():
1184
+ return {"success": False, "error": "spec/ directory not found"}
1185
+
1186
+ # Search for the requirement in spec files
1187
+ target_file = None
1188
+ for md_file in spec_dir.rglob("*.md"):
1189
+ content = md_file.read_text(encoding="utf-8")
1190
+ if f"## {req_id}:" in content or f"### {req_id}:" in content:
1191
+ target_file = md_file
1192
+ break
1193
+
1194
+ if target_file is None:
1195
+ return {"success": False, "error": f"Requirement {req_id} not found in spec files"}
1196
+
1197
+ content = target_file.read_text(encoding="utf-8")
1198
+
1199
+ # Create safety branch if requested
1200
+ safety_branch = None
1201
+ if save_branch:
1202
+ branch_result = create_safety_branch(repo_root, req_id)
1203
+ if not branch_result["success"]:
1204
+ error_msg = branch_result.get("error", "Failed to create safety branch")
1205
+ return {"success": False, "error": error_msg}
1206
+ safety_branch = branch_result["branch_name"]
1207
+
1208
+ # Find and replace the reference pattern
1209
+ # Match patterns like: **Implements**: REQ-p00001 or **Refines**: REQ-p00001
1210
+ old_patterns = [
1211
+ f"**Implements**: {target_id}",
1212
+ f"**Refines**: {target_id}",
1213
+ f"Implements: {target_id}",
1214
+ f"Refines: {target_id}",
1215
+ ]
1216
+
1217
+ # Capitalize for display
1218
+ new_type_display = new_type_lower.capitalize()
1219
+ new_text = f"**{new_type_display}**: {target_id}"
1220
+
1221
+ modified = False
1222
+ for pattern in old_patterns:
1223
+ if pattern in content:
1224
+ content = content.replace(pattern, new_text)
1225
+ modified = True
1226
+ break
1227
+
1228
+ if not modified:
1229
+ return {"success": False, "error": f"Reference to {target_id} not found in {req_id}"}
1230
+
1231
+ # Write the modified content
1232
+ target_file.write_text(content, encoding="utf-8")
1233
+
1234
+ result: dict[str, Any] = {"success": True}
1235
+ if safety_branch:
1236
+ result["safety_branch"] = safety_branch
1237
+
1238
+ return result
1239
+
1240
+
1241
+ def _move_requirement(
1242
+ repo_root: Path,
1243
+ req_id: str,
1244
+ target_file: str,
1245
+ save_branch: bool = False,
1246
+ ) -> dict[str, Any]:
1247
+ """Move a requirement from one spec file to another.
1248
+
1249
+ REQ-o00063-B: Relocate a requirement between spec files.
1250
+
1251
+ Args:
1252
+ repo_root: Repository root path.
1253
+ req_id: ID of the requirement to move.
1254
+ target_file: Relative path to the target file.
1255
+ save_branch: If True, create a safety branch before modifying.
1256
+
1257
+ Returns:
1258
+ Success status and optional safety_branch name.
1259
+ """
1260
+ from elspais.utilities.git import create_safety_branch
1261
+
1262
+ spec_dir = repo_root / "spec"
1263
+ if not spec_dir.exists():
1264
+ return {"success": False, "error": "spec/ directory not found"}
1265
+
1266
+ target_path = repo_root / target_file
1267
+ if not target_path.exists():
1268
+ return {"success": False, "error": f"Target file {target_file} not found"}
1269
+
1270
+ # Find the source file containing req_id
1271
+ source_file = None
1272
+ for md_file in spec_dir.rglob("*.md"):
1273
+ content = md_file.read_text(encoding="utf-8")
1274
+ if f"## {req_id}:" in content or f"### {req_id}:" in content:
1275
+ source_file = md_file
1276
+ break
1277
+
1278
+ if source_file is None:
1279
+ return {"success": False, "error": f"Requirement {req_id} not found in spec files"}
1280
+
1281
+ if source_file == target_path:
1282
+ return {"success": False, "error": "Source and target files are the same"}
1283
+
1284
+ # Create safety branch if requested
1285
+ safety_branch = None
1286
+ if save_branch:
1287
+ branch_result = create_safety_branch(repo_root, req_id)
1288
+ if not branch_result["success"]:
1289
+ error_msg = branch_result.get("error", "Failed to create safety branch")
1290
+ return {"success": False, "error": error_msg}
1291
+ safety_branch = branch_result["branch_name"]
1292
+
1293
+ # Extract the requirement block from source
1294
+ source_content = source_file.read_text(encoding="utf-8")
1295
+
1296
+ # Find the requirement block (from header to *End* marker or next ## header)
1297
+ # Pattern: ## REQ-xxx: Title ... *End* *Title* | **Hash**: xxx
1298
+ header_pattern = re.compile(
1299
+ rf"(^##+ {re.escape(req_id)}:.*?(?:\*End\*.*?(?:\*\*Hash\*\*:.*?\n|$)|(?=^##+ REQ-)))",
1300
+ re.MULTILINE | re.DOTALL,
1301
+ )
1302
+
1303
+ match = header_pattern.search(source_content)
1304
+ if not match:
1305
+ return {"success": False, "error": f"Could not parse requirement block for {req_id}"}
1306
+
1307
+ req_block = match.group(1)
1308
+
1309
+ # Remove from source
1310
+ new_source_content = source_content[: match.start()] + source_content[match.end() :]
1311
+ # Clean up multiple consecutive blank lines
1312
+ new_source_content = re.sub(r"\n{3,}", "\n\n", new_source_content)
17
1313
 
18
- from elspais.mcp.context import WorkspaceContext
19
- from elspais.mcp.serializers import (
20
- serialize_content_rule,
21
- serialize_requirement,
22
- serialize_requirement_summary,
23
- serialize_violation,
24
- )
1314
+ # Append to target
1315
+ target_content = target_path.read_text(encoding="utf-8")
1316
+ if not target_content.endswith("\n\n"):
1317
+ if not target_content.endswith("\n"):
1318
+ target_content += "\n"
1319
+ target_content += "\n"
1320
+ target_content += "---\n\n" + req_block
25
1321
 
1322
+ # Write both files
1323
+ source_file.write_text(new_source_content, encoding="utf-8")
1324
+ target_path.write_text(target_content, encoding="utf-8")
26
1325
 
27
- def create_server(working_dir: Optional[Path] = None) -> "FastMCP":
1326
+ result: dict[str, Any] = {"success": True}
1327
+ if safety_branch:
1328
+ result["safety_branch"] = safety_branch
1329
+
1330
+ return result
1331
+
1332
+
1333
+ def _restore_from_safety_branch(
1334
+ repo_root: Path,
1335
+ branch_name: str,
1336
+ ) -> dict[str, Any]:
1337
+ """Restore spec files from a safety branch.
1338
+
1339
+ REQ-o00063-E: Revert file changes from a safety branch.
1340
+
1341
+ Args:
1342
+ repo_root: Repository root path.
1343
+ branch_name: Name of the safety branch to restore from.
1344
+
1345
+ Returns:
1346
+ Success status.
28
1347
  """
29
- Create and configure the MCP server.
1348
+ from elspais.utilities.git import restore_from_safety_branch
1349
+
1350
+ return restore_from_safety_branch(repo_root, branch_name)
1351
+
1352
+
1353
+ def _list_safety_branches_impl(repo_root: Path) -> dict[str, Any]:
1354
+ """List all safety branches.
30
1355
 
31
1356
  Args:
32
- working_dir: Working directory for finding .elspais.toml
33
- Defaults to current working directory
1357
+ repo_root: Repository root path.
34
1358
 
35
1359
  Returns:
36
- Configured FastMCP server instance
1360
+ List of safety branch names.
1361
+ """
1362
+ from elspais.utilities.git import list_safety_branches
1363
+
1364
+ branches = list_safety_branches(repo_root)
1365
+ return {"branches": branches, "count": len(branches)}
1366
+
1367
+
1368
+ # ─────────────────────────────────────────────────────────────────────────────
1369
+ # MCP Server Instructions
1370
+ # ─────────────────────────────────────────────────────────────────────────────
1371
+
1372
+ MCP_SERVER_INSTRUCTIONS = """\
1373
+ elspais MCP Server - AI-Driven Requirements Management
1374
+
1375
+ This server provides tools to navigate, analyze, and mutate a requirements traceability graph.
1376
+ The graph is the single source of truth - all tools read directly from it.
1377
+
1378
+ ## Quick Start
1379
+
1380
+ 1. `get_workspace_info()` - Understand what project you're working with
1381
+ 2. `get_project_summary()` - Get overview statistics and health metrics
1382
+ 3. `search(query)` - Find requirements by keyword
1383
+ 4. `get_requirement(req_id)` - Get full details including assertions
1384
+ 5. `get_hierarchy(req_id)` - Navigate parent/child relationships
1385
+
1386
+ ## Tools Overview
1387
+
1388
+ ### Graph Status & Control
1389
+ - `get_graph_status()` - Node counts, orphan/broken reference flags
1390
+ - `refresh_graph(full=False)` - Rebuild after spec file changes
1391
+
1392
+ ### Search & Navigation
1393
+ - `search(query, field="all", regex=False, limit=50)` - Find requirements
1394
+ - field: "id", "title", "body", or "all"
1395
+ - regex: treat query as regex pattern
1396
+ - `get_requirement(req_id)` - Full details with assertions and relationships
1397
+ - `get_hierarchy(req_id)` - Ancestors (to roots) and direct children
1398
+
1399
+ ### Workspace Context
1400
+ - `get_workspace_info()` - Repo path, project name, configuration
1401
+ - `get_project_summary()` - Counts by level, coverage stats, change metrics
1402
+
1403
+ ### Node Mutations (in-memory)
1404
+ - `mutate_rename_node(old_id, new_id)` - Rename requirement
1405
+ - `mutate_update_title(node_id, new_title)` - Change title
1406
+ - `mutate_change_status(node_id, new_status)` - Change status
1407
+ - `mutate_add_requirement(req_id, title, level, ...)` - Create requirement
1408
+ - `mutate_delete_requirement(node_id, confirm=True)` - Delete requirement (requires confirm)
1409
+
1410
+ ### Assertion Mutations (in-memory)
1411
+ - `mutate_add_assertion(req_id, label, text)` - Add assertion
1412
+ - `mutate_update_assertion(assertion_id, new_text)` - Update text
1413
+ - `mutate_delete_assertion(assertion_id, confirm=True)` - Delete (requires confirm)
1414
+ - `mutate_rename_assertion(old_id, new_label)` - Rename label
1415
+
1416
+ ### Edge Mutations (in-memory)
1417
+ - `mutate_add_edge(source_id, target_id, edge_kind)` - Add relationship
1418
+ - `mutate_change_edge_kind(source_id, target_id, new_kind)` - Change type
1419
+ - `mutate_delete_edge(source_id, target_id, confirm=True)` - Delete (requires confirm)
1420
+ - `mutate_fix_broken_reference(source_id, old_target, new_target)` - Fix broken ref
1421
+
1422
+ ### Undo & Inspection
1423
+ - `undo_last_mutation()` - Undo most recent mutation
1424
+ - `undo_to_mutation(mutation_id)` - Undo back to specific point
1425
+ - `get_mutation_log(limit=50)` - View mutation history
1426
+ - `get_orphaned_nodes()` - List orphaned nodes
1427
+ - `get_broken_references()` - List broken references
1428
+
1429
+ ### Test Coverage Analysis
1430
+ - `get_test_coverage(req_id)` - Get TEST nodes and coverage stats for a requirement
1431
+ - Returns test_nodes, result_nodes, covered/uncovered assertions
1432
+ - Includes coverage percentage calculation
1433
+ - `get_uncovered_assertions(req_id=None)` - Find assertions with no test coverage
1434
+ - When req_id is None, scans all requirements
1435
+ - Returns assertion details with parent requirement context
1436
+ - `find_assertions_by_keywords(keywords, match_all=True)` - Search assertion text
1437
+ - match_all=True requires ALL keywords, False requires ANY
1438
+ - Complements find_by_keywords() which searches requirement titles
1439
+
1440
+ ## Requirement Levels
1441
+
1442
+ Requirements follow a three-tier hierarchy:
1443
+ - **PRD** (Product): High-level product requirements
1444
+ - **OPS** (Operations): Operational/process requirements
1445
+ - **DEV** (Development): Technical implementation requirements
1446
+
1447
+ Children implement parents: DEV -> OPS -> PRD
1448
+
1449
+ **Note:** The exact ID syntax (prefixes, patterns) and hierarchy rules are
1450
+ configurable per project via `.elspais.toml`. Use `get_workspace_info()` to
1451
+ see the current project's configuration including the ID prefix and pattern.
1452
+
1453
+ ## Important: In-Memory vs File Mutations
1454
+
1455
+ Mutation tools modify the **in-memory graph only**. Changes are NOT persisted
1456
+ to spec files automatically. This allows you to:
1457
+ 1. Draft changes and review them
1458
+ 2. Use undo to revert mistakes
1459
+ 3. Refresh the graph to discard all changes
1460
+
1461
+ To persist changes, use the file mutation tools:
1462
+
1463
+ ### File Mutations (persistent)
1464
+ - `change_reference_type(req_id, target_id, new_type, save_branch)` - Change Implements/Refines
1465
+ - `move_requirement(req_id, target_file, save_branch)` - Move requirement to different file
1466
+ - `restore_from_safety_branch(branch_name)` - Revert file changes
1467
+ - `list_safety_branches()` - List available safety branches
1468
+
1469
+ Use `save_branch=True` to create a safety branch before modifications, allowing rollback.
1470
+
1471
+ ## Common Patterns
1472
+
1473
+ **Understanding a requirement:**
1474
+ 1. get_requirement("REQ-p00001") for details and assertions
1475
+ 2. get_hierarchy("REQ-p00001") to see where it fits
1476
+
1477
+ **Finding related requirements:**
1478
+ 1. search("authentication") to find by keyword
1479
+ 2. get_hierarchy() on results to navigate relationships
1480
+
1481
+ **Checking project health:**
1482
+ 1. get_graph_status() for orphans/broken refs
1483
+ 2. get_project_summary() for coverage gaps
1484
+
1485
+ **Drafting requirement changes:**
1486
+ 1. mutate_add_requirement() to create draft
1487
+ 2. mutate_add_assertion() to add assertions
1488
+ 3. get_mutation_log() to review changes
1489
+ 4. undo_last_mutation() if needed
1490
+
1491
+ **After editing spec files:**
1492
+ 1. refresh_graph() to rebuild
1493
+ 2. get_graph_status() to verify health
1494
+ """
1495
+
1496
+
1497
+ # ─────────────────────────────────────────────────────────────────────────────
1498
+ # MCP Server Factory
1499
+ # ─────────────────────────────────────────────────────────────────────────────
1500
+
37
1501
 
38
- Raises:
39
- ImportError: If MCP dependencies are not installed
1502
+ def create_server(
1503
+ graph: TraceGraph | None = None,
1504
+ working_dir: Path | None = None,
1505
+ ) -> FastMCP:
1506
+ """Create the MCP server with all tools registered.
1507
+
1508
+ Args:
1509
+ graph: Optional pre-built graph (for testing).
1510
+ working_dir: Working directory for graph building.
1511
+
1512
+ Returns:
1513
+ FastMCP server instance.
40
1514
  """
41
1515
  if not MCP_AVAILABLE:
42
- raise ImportError(
43
- "MCP dependencies not installed. " "Install with: pip install elspais[mcp]"
44
- )
1516
+ raise ImportError("MCP dependencies not installed. Install with: pip install elspais[mcp]")
45
1517
 
1518
+ # Initialize working directory
46
1519
  if working_dir is None:
47
1520
  working_dir = Path.cwd()
48
1521
 
49
- # Initialize workspace context
50
- ctx = WorkspaceContext.from_directory(working_dir)
1522
+ # Build initial graph if not provided
1523
+ if graph is None:
1524
+ graph = build_graph(repo_root=working_dir)
51
1525
 
52
- # Create FastMCP server
53
- mcp = FastMCP(
54
- name="elspais",
55
- )
1526
+ # Create server with instructions for AI agents (REQ-d00065)
1527
+ mcp = FastMCP("elspais", instructions=MCP_SERVER_INSTRUCTIONS)
56
1528
 
57
- # Register resources
58
- _register_resources(mcp, ctx)
1529
+ # Store graph in closure for tools
1530
+ _state = {"graph": graph, "working_dir": working_dir}
59
1531
 
60
- # Register tools
61
- _register_tools(mcp, ctx)
1532
+ # ─────────────────────────────────────────────────────────────────────
1533
+ # Register Tools
1534
+ # ─────────────────────────────────────────────────────────────────────
62
1535
 
63
- return mcp
1536
+ @mcp.tool()
1537
+ def get_graph_status() -> dict[str, Any]:
1538
+ """Get current graph status.
64
1539
 
1540
+ Returns node counts by kind, root count, and detection flags.
1541
+ Use this to check graph health and staleness.
1542
+ """
1543
+ return _get_graph_status(_state["graph"])
1544
+
1545
+ @mcp.tool()
1546
+ def refresh_graph(full: bool = False) -> dict[str, Any]:
1547
+ """Force graph rebuild from spec files.
65
1548
 
66
- def _register_resources(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
67
- """Register MCP resources."""
1549
+ Args:
1550
+ full: If True, clear all caches before rebuild.
68
1551
 
69
- @mcp.resource("requirements://all")
70
- def list_all_requirements() -> str:
1552
+ Returns:
1553
+ Success status and new node count.
71
1554
  """
72
- Get list of all requirements in the workspace.
1555
+ result, new_graph = _refresh_graph(_state["working_dir"], full=full)
1556
+ _state["graph"] = new_graph
1557
+ return result
1558
+
1559
+ @mcp.tool()
1560
+ def search(
1561
+ query: str,
1562
+ field: str = "all",
1563
+ regex: bool = False,
1564
+ limit: int = 50,
1565
+ ) -> list[dict[str, Any]]:
1566
+ """Search requirements by ID, title, or content.
1567
+
1568
+ Args:
1569
+ query: Search string or regex pattern.
1570
+ field: Field to search: 'id', 'title', 'body', or 'all'.
1571
+ regex: If True, treat query as regex pattern.
1572
+ limit: Maximum results to return (default 50).
73
1573
 
74
- Returns summary information for each requirement including
75
- ID, title, level, status, and assertion count.
1574
+ Returns:
1575
+ List of matching requirement summaries.
76
1576
  """
77
- import json
1577
+ return _search(_state["graph"], query, field, regex, limit)
78
1578
 
79
- requirements = ctx.get_requirements()
80
- return json.dumps(
81
- {
82
- "count": len(requirements),
83
- "requirements": [
84
- serialize_requirement_summary(req) for req in requirements.values()
85
- ],
86
- },
87
- indent=2,
88
- )
1579
+ @mcp.tool()
1580
+ def get_requirement(req_id: str) -> dict[str, Any]:
1581
+ """Get full details for a single requirement.
1582
+
1583
+ Args:
1584
+ req_id: The requirement ID (e.g., 'REQ-p00001').
89
1585
 
90
- @mcp.resource("requirements://{req_id}")
91
- def get_requirement_resource(req_id: str) -> str:
1586
+ Returns:
1587
+ Requirement details including assertions and relationships.
92
1588
  """
93
- Get detailed information about a specific requirement.
1589
+ return _get_requirement(_state["graph"], req_id)
1590
+
1591
+ @mcp.tool()
1592
+ def get_hierarchy(req_id: str) -> dict[str, Any]:
1593
+ """Get requirement hierarchy (ancestors and children).
1594
+
1595
+ Args:
1596
+ req_id: The requirement ID.
94
1597
 
95
- Returns full requirement data including body, assertions,
96
- implements references, and location.
1598
+ Returns:
1599
+ Ancestors (walking up to roots) and direct children.
97
1600
  """
98
- import json
1601
+ return _get_hierarchy(_state["graph"], req_id)
99
1602
 
100
- req = ctx.get_requirement(req_id)
101
- if req is None:
102
- return json.dumps({"error": f"Requirement {req_id} not found"})
103
- return json.dumps(serialize_requirement(req), indent=2)
1603
+ # ─────────────────────────────────────────────────────────────────────
1604
+ # Workspace Context Tools (REQ-o00061)
1605
+ # ─────────────────────────────────────────────────────────────────────
104
1606
 
105
- @mcp.resource("requirements://level/{level}")
106
- def get_requirements_by_level(level: str) -> str:
107
- """Get all requirements of a specific level (PRD, OPS, DEV)."""
108
- import json
1607
+ @mcp.tool()
1608
+ def get_workspace_info() -> dict[str, Any]:
1609
+ """Get information about the current workspace.
109
1610
 
110
- requirements = ctx.get_requirements()
111
- filtered = [r for r in requirements.values() if r.level.upper() == level.upper()]
112
- return json.dumps(
113
- {
114
- "level": level,
115
- "count": len(filtered),
116
- "requirements": [serialize_requirement_summary(r) for r in filtered],
117
- },
118
- indent=2,
119
- )
1611
+ Returns repository path, project name, and configuration summary.
1612
+ Use this to understand what project you're working with.
120
1613
 
121
- @mcp.resource("content-rules://list")
122
- def list_content_rules() -> str:
123
- """List all configured content rule files."""
124
- import json
1614
+ Returns:
1615
+ Workspace information including repo path, project name, and config.
1616
+ """
1617
+ return _get_workspace_info(_state["working_dir"])
125
1618
 
126
- rules = ctx.get_content_rules()
127
- return json.dumps(
128
- {
129
- "count": len(rules),
130
- "rules": [
131
- {
132
- "file": str(r.file_path),
133
- "title": r.title,
134
- "type": r.type,
135
- "applies_to": r.applies_to,
136
- }
137
- for r in rules
138
- ],
139
- },
140
- indent=2,
1619
+ @mcp.tool()
1620
+ def get_project_summary() -> dict[str, Any]:
1621
+ """Get summary statistics for the project.
1622
+
1623
+ Returns requirement counts by level (PRD/OPS/DEV), coverage statistics,
1624
+ and change metrics (uncommitted, branch changed).
1625
+
1626
+ Returns:
1627
+ Project summary with counts, coverage, and change metrics.
1628
+ """
1629
+ return _get_project_summary(_state["graph"], _state["working_dir"])
1630
+
1631
+ # ─────────────────────────────────────────────────────────────────────
1632
+ # Node Mutation Tools (REQ-o00062-A)
1633
+ # ─────────────────────────────────────────────────────────────────────
1634
+
1635
+ @mcp.tool()
1636
+ def mutate_rename_node(old_id: str, new_id: str) -> dict[str, Any]:
1637
+ """Rename a requirement node.
1638
+
1639
+ Updates the node's ID and all references to it.
1640
+
1641
+ Args:
1642
+ old_id: Current node ID.
1643
+ new_id: New node ID.
1644
+
1645
+ Returns:
1646
+ Success status and mutation entry for undo.
1647
+ """
1648
+ return _mutate_rename_node(_state["graph"], old_id, new_id)
1649
+
1650
+ @mcp.tool()
1651
+ def mutate_update_title(node_id: str, new_title: str) -> dict[str, Any]:
1652
+ """Update a requirement's title.
1653
+
1654
+ Does not affect the content hash.
1655
+
1656
+ Args:
1657
+ node_id: The requirement ID.
1658
+ new_title: New title text.
1659
+
1660
+ Returns:
1661
+ Success status and mutation entry for undo.
1662
+ """
1663
+ return _mutate_update_title(_state["graph"], node_id, new_title)
1664
+
1665
+ @mcp.tool()
1666
+ def mutate_change_status(node_id: str, new_status: str) -> dict[str, Any]:
1667
+ """Change a requirement's status.
1668
+
1669
+ Args:
1670
+ node_id: The requirement ID.
1671
+ new_status: New status (e.g., 'Active', 'Draft', 'Deprecated').
1672
+
1673
+ Returns:
1674
+ Success status and mutation entry for undo.
1675
+ """
1676
+ return _mutate_change_status(_state["graph"], node_id, new_status)
1677
+
1678
+ @mcp.tool()
1679
+ def mutate_add_requirement(
1680
+ req_id: str,
1681
+ title: str,
1682
+ level: str,
1683
+ status: str = "Draft",
1684
+ parent_id: str | None = None,
1685
+ edge_kind: str | None = None,
1686
+ ) -> dict[str, Any]:
1687
+ """Create a new requirement.
1688
+
1689
+ Args:
1690
+ req_id: ID for the new requirement.
1691
+ title: Requirement title.
1692
+ level: Level (PRD, OPS, DEV).
1693
+ status: Initial status (default 'Draft').
1694
+ parent_id: Optional parent requirement to link to.
1695
+ edge_kind: Edge type if parent_id set ('IMPLEMENTS' or 'REFINES').
1696
+
1697
+ Returns:
1698
+ Success status and mutation entry for undo.
1699
+ """
1700
+ return _mutate_add_requirement(
1701
+ _state["graph"], req_id, title, level, status, parent_id, edge_kind
141
1702
  )
142
1703
 
143
- @mcp.resource("content-rules://{filename}")
144
- def get_content_rule(filename: str) -> str:
1704
+ @mcp.tool()
1705
+ def mutate_delete_requirement(node_id: str, confirm: bool = False) -> dict[str, Any]:
1706
+ """Delete a requirement.
1707
+
1708
+ DESTRUCTIVE: Requires confirm=True to execute.
1709
+
1710
+ Args:
1711
+ node_id: The requirement ID to delete.
1712
+ confirm: Must be True to confirm deletion.
1713
+
1714
+ Returns:
1715
+ Success status and mutation entry for undo.
1716
+ """
1717
+ return _mutate_delete_requirement(_state["graph"], node_id, confirm)
1718
+
1719
+ # ─────────────────────────────────────────────────────────────────────
1720
+ # Assertion Mutation Tools (REQ-o00062-B)
1721
+ # ─────────────────────────────────────────────────────────────────────
1722
+
1723
+ @mcp.tool()
1724
+ def mutate_add_assertion(req_id: str, label: str, text: str) -> dict[str, Any]:
1725
+ """Add an assertion to a requirement.
1726
+
1727
+ Args:
1728
+ req_id: Parent requirement ID.
1729
+ label: Assertion label (e.g., 'A', 'B', 'C').
1730
+ text: Assertion text (SHALL statement).
1731
+
1732
+ Returns:
1733
+ Success status and mutation entry for undo.
145
1734
  """
146
- Get content of a content rule markdown file.
1735
+ return _mutate_add_assertion(_state["graph"], req_id, label, text)
1736
+
1737
+ @mcp.tool()
1738
+ def mutate_update_assertion(assertion_id: str, new_text: str) -> dict[str, Any]:
1739
+ """Update an assertion's text.
147
1740
 
148
- Content rules are documentation files that describe
149
- requirement formats and authoring guidelines.
1741
+ Recomputes the parent requirement's hash.
1742
+
1743
+ Args:
1744
+ assertion_id: The assertion ID (e.g., 'REQ-p00001-A').
1745
+ new_text: New assertion text.
1746
+
1747
+ Returns:
1748
+ Success status and mutation entry for undo.
150
1749
  """
151
- import json
1750
+ return _mutate_update_assertion(_state["graph"], assertion_id, new_text)
1751
+
1752
+ @mcp.tool()
1753
+ def mutate_delete_assertion(
1754
+ assertion_id: str, compact: bool = True, confirm: bool = False
1755
+ ) -> dict[str, Any]:
1756
+ """Delete an assertion.
1757
+
1758
+ DESTRUCTIVE: Requires confirm=True to execute.
1759
+
1760
+ Args:
1761
+ assertion_id: The assertion ID to delete.
1762
+ compact: If True, renumber subsequent assertions.
1763
+ confirm: Must be True to confirm deletion.
152
1764
 
153
- rules = ctx.get_content_rules()
154
- for rule in rules:
155
- if rule.file_path.name == filename or str(rule.file_path).endswith(filename):
156
- return json.dumps(serialize_content_rule(rule), indent=2)
157
- return json.dumps({"error": f"Content rule not found: {filename}"})
1765
+ Returns:
1766
+ Success status and mutation entry for undo.
1767
+ """
1768
+ return _mutate_delete_assertion(_state["graph"], assertion_id, compact, confirm)
158
1769
 
159
- @mcp.resource("config://current")
160
- def get_current_config() -> str:
161
- """Get the current elspais configuration."""
162
- import json
1770
+ @mcp.tool()
1771
+ def mutate_rename_assertion(old_id: str, new_label: str) -> dict[str, Any]:
1772
+ """Rename an assertion's label.
163
1773
 
164
- return json.dumps(ctx.config, indent=2, default=str)
1774
+ Args:
1775
+ old_id: Current assertion ID (e.g., 'REQ-p00001-A').
1776
+ new_label: New label (e.g., 'X').
165
1777
 
1778
+ Returns:
1779
+ Success status and mutation entry for undo.
1780
+ """
1781
+ return _mutate_rename_assertion(_state["graph"], old_id, new_label)
166
1782
 
167
- def _register_tools(mcp: "FastMCP", ctx: WorkspaceContext) -> None:
168
- """Register MCP tools."""
1783
+ # ─────────────────────────────────────────────────────────────────────
1784
+ # Edge Mutation Tools (REQ-o00062-C)
1785
+ # ─────────────────────────────────────────────────────────────────────
169
1786
 
170
1787
  @mcp.tool()
171
- def validate(skip_rules: Optional[List[str]] = None) -> Dict[str, Any]:
1788
+ def mutate_add_edge(
1789
+ source_id: str,
1790
+ target_id: str,
1791
+ edge_kind: str,
1792
+ assertion_targets: list[str] | None = None,
1793
+ ) -> dict[str, Any]:
1794
+ """Add an edge between nodes.
1795
+
1796
+ Args:
1797
+ source_id: Child node ID.
1798
+ target_id: Parent node ID.
1799
+ edge_kind: Relationship type ('IMPLEMENTS' or 'REFINES').
1800
+ assertion_targets: Optional list of assertion IDs to target.
1801
+
1802
+ Returns:
1803
+ Success status and mutation entry for undo.
172
1804
  """
173
- Validate all requirements in the workspace.
1805
+ return _mutate_add_edge(_state["graph"], source_id, target_id, edge_kind, assertion_targets)
174
1806
 
175
- Checks format, hierarchy relationships, hashes, and links.
176
- Returns violations grouped by severity.
1807
+ @mcp.tool()
1808
+ def mutate_change_edge_kind(source_id: str, target_id: str, new_kind: str) -> dict[str, Any]:
1809
+ """Change an edge's relationship type.
177
1810
 
178
1811
  Args:
179
- skip_rules: Optional list of rule names to skip
1812
+ source_id: Child node ID.
1813
+ target_id: Parent node ID.
1814
+ new_kind: New relationship type ('IMPLEMENTS' or 'REFINES').
1815
+
1816
+ Returns:
1817
+ Success status and mutation entry for undo.
180
1818
  """
181
- from elspais.core.rules import RuleEngine, RulesConfig, Severity
1819
+ return _mutate_change_edge_kind(_state["graph"], source_id, target_id, new_kind)
1820
+
1821
+ @mcp.tool()
1822
+ def mutate_delete_edge(source_id: str, target_id: str, confirm: bool = False) -> dict[str, Any]:
1823
+ """Delete an edge between nodes.
182
1824
 
183
- requirements = ctx.get_requirements(force_refresh=True)
184
- rules_config = RulesConfig.from_dict(ctx.config.get("rules", {}))
185
- engine = RuleEngine(rules_config)
1825
+ DESTRUCTIVE: Requires confirm=True to execute.
186
1826
 
187
- violations = engine.validate(requirements)
1827
+ Args:
1828
+ source_id: Child node ID.
1829
+ target_id: Parent node ID.
1830
+ confirm: Must be True to confirm deletion.
188
1831
 
189
- # Filter by skip_rules
190
- if skip_rules:
191
- violations = [v for v in violations if v.rule_name not in skip_rules]
1832
+ Returns:
1833
+ Success status and mutation entry for undo.
1834
+ """
1835
+ return _mutate_delete_edge(_state["graph"], source_id, target_id, confirm)
192
1836
 
193
- errors = [v for v in violations if v.severity == Severity.ERROR]
194
- warnings = [v for v in violations if v.severity == Severity.WARNING]
1837
+ @mcp.tool()
1838
+ def mutate_fix_broken_reference(
1839
+ source_id: str, old_target_id: str, new_target_id: str
1840
+ ) -> dict[str, Any]:
1841
+ """Fix a broken reference by redirecting to a valid target.
195
1842
 
196
- return {
197
- "valid": len(errors) == 0,
198
- "errors": [serialize_violation(v) for v in errors],
199
- "warnings": [serialize_violation(v) for v in warnings],
200
- "summary": (
201
- f"{len(errors)} errors, {len(warnings)} warnings "
202
- f"in {len(requirements)} requirements"
203
- ),
204
- }
1843
+ Args:
1844
+ source_id: Node with the broken reference.
1845
+ old_target_id: Invalid target ID.
1846
+ new_target_id: Valid target ID to redirect to.
1847
+
1848
+ Returns:
1849
+ Success status and mutation entry for undo.
1850
+ """
1851
+ return _mutate_fix_broken_reference(
1852
+ _state["graph"], source_id, old_target_id, new_target_id
1853
+ )
1854
+
1855
+ # ─────────────────────────────────────────────────────────────────────
1856
+ # Undo & Inspection Tools (REQ-o00062-G)
1857
+ # ─────────────────────────────────────────────────────────────────────
205
1858
 
206
1859
  @mcp.tool()
207
- def parse_requirement(text: str, file_path: Optional[str] = None) -> Dict[str, Any]:
1860
+ def undo_last_mutation() -> dict[str, Any]:
1861
+ """Undo the most recent mutation.
1862
+
1863
+ Returns:
1864
+ Success status and the mutation that was undone.
208
1865
  """
209
- Parse requirement text and extract structured data.
1866
+ return _undo_last_mutation(_state["graph"])
1867
+
1868
+ @mcp.tool()
1869
+ def undo_to_mutation(mutation_id: str) -> dict[str, Any]:
1870
+ """Undo all mutations back to a specific point.
210
1871
 
211
1872
  Args:
212
- text: Markdown text containing one or more requirements
213
- file_path: Optional source file path for location info
1873
+ mutation_id: ID of the mutation to undo back to (inclusive).
1874
+
1875
+ Returns:
1876
+ Success status and list of mutations undone.
214
1877
  """
215
- from elspais.core.parser import RequirementParser
216
- from elspais.core.patterns import PatternConfig
1878
+ return _undo_to_mutation(_state["graph"], mutation_id)
217
1879
 
218
- pattern_config = PatternConfig.from_dict(ctx.config.get("patterns", {}))
219
- parser = RequirementParser(pattern_config)
220
- path = Path(file_path) if file_path else None
221
- requirements = parser.parse_text(text, file_path=path)
1880
+ @mcp.tool()
1881
+ def get_mutation_log(limit: int = 50) -> dict[str, Any]:
1882
+ """Get mutation history.
222
1883
 
223
- return {
224
- "count": len(requirements),
225
- "requirements": {
226
- req_id: serialize_requirement(req) for req_id, req in requirements.items()
227
- },
228
- }
1884
+ Args:
1885
+ limit: Maximum number of mutations to return.
1886
+
1887
+ Returns:
1888
+ List of recent mutations.
1889
+ """
1890
+ return _get_mutation_log(_state["graph"], limit)
229
1891
 
230
1892
  @mcp.tool()
231
- def search(
232
- query: str,
233
- field: str = "all",
234
- regex: bool = False,
235
- ) -> Dict[str, Any]:
1893
+ def get_orphaned_nodes() -> dict[str, Any]:
1894
+ """Get all orphaned nodes.
1895
+
1896
+ Returns nodes that have no parent relationships.
1897
+
1898
+ Returns:
1899
+ List of orphaned nodes with summaries.
1900
+ """
1901
+ return _get_orphaned_nodes(_state["graph"])
1902
+
1903
+ @mcp.tool()
1904
+ def get_broken_references() -> dict[str, Any]:
1905
+ """Get all broken references.
1906
+
1907
+ Returns edges that point to non-existent nodes.
1908
+
1909
+ Returns:
1910
+ List of broken references.
236
1911
  """
237
- Search requirements by pattern.
1912
+ return _get_broken_references(_state["graph"])
1913
+
1914
+ # ─────────────────────────────────────────────────────────────────────
1915
+ # Keyword Search Tools (Phase 4)
1916
+ # ─────────────────────────────────────────────────────────────────────
1917
+
1918
+ @mcp.tool()
1919
+ def find_by_keywords(
1920
+ keywords: list[str],
1921
+ match_all: bool = True,
1922
+ ) -> dict[str, Any]:
1923
+ """Find requirements containing specified keywords.
1924
+
1925
+ Keywords are extracted from requirement titles and assertion text.
238
1926
 
239
1927
  Args:
240
- query: Search query string
241
- field: Field to search - "all", "id", "title", "body", "assertions"
242
- regex: If true, treat query as regex pattern
1928
+ keywords: List of keywords to search for.
1929
+ match_all: If True, requirement must contain ALL keywords (AND).
1930
+ If False, requirement must contain ANY keyword (OR).
1931
+
1932
+ Returns:
1933
+ List of matching requirements with their summaries.
243
1934
  """
244
- results = ctx.search_requirements(query, field, regex)
245
- return {
246
- "count": len(results),
247
- "query": query,
248
- "field": field,
249
- "requirements": [serialize_requirement_summary(r) for r in results],
250
- }
1935
+ return _find_by_keywords(_state["graph"], keywords, match_all)
251
1936
 
252
1937
  @mcp.tool()
253
- def get_requirement(req_id: str) -> Dict[str, Any]:
1938
+ def get_all_keywords() -> dict[str, Any]:
1939
+ """Get all unique keywords from the graph.
1940
+
1941
+ Keywords are extracted from requirement titles and assertion text.
1942
+ Use this to discover available keywords for filtering.
1943
+
1944
+ Returns:
1945
+ Sorted list of all unique keywords and total count.
254
1946
  """
255
- Get complete details for a single requirement.
1947
+ return _get_all_keywords(_state["graph"])
1948
+
1949
+ # ─────────────────────────────────────────────────────────────────────
1950
+ # Test Coverage Tools (REQ-o00064)
1951
+ # ─────────────────────────────────────────────────────────────────────
1952
+
1953
+ @mcp.tool()
1954
+ def get_test_coverage(req_id: str) -> dict[str, Any]:
1955
+ """Get test coverage for a requirement.
1956
+
1957
+ Returns TEST nodes that reference the requirement and their TEST_RESULT nodes.
1958
+ Identifies assertion coverage gaps (assertions with no tests).
256
1959
 
257
1960
  Args:
258
- req_id: The requirement ID (e.g., "REQ-p00001")
1961
+ req_id: The requirement ID to get coverage for.
1962
+
1963
+ Returns:
1964
+ Test nodes, result nodes, covered/uncovered assertions, and coverage percentage.
259
1965
  """
260
- req = ctx.get_requirement(req_id)
261
- if req is None:
262
- return {"error": f"Requirement {req_id} not found"}
263
- return serialize_requirement(req)
1966
+ return _get_test_coverage(_state["graph"], req_id)
264
1967
 
265
1968
  @mcp.tool()
266
- def analyze(analysis_type: str = "hierarchy") -> Dict[str, Any]:
1969
+ def get_uncovered_assertions(req_id: str | None = None) -> dict[str, Any]:
1970
+ """Get all assertions lacking test coverage.
1971
+
1972
+ Returns assertions that have no TEST node references.
1973
+ Include parent requirement context in results.
1974
+
1975
+ Args:
1976
+ req_id: Optional requirement ID. When None, scan all requirements.
1977
+
1978
+ Returns:
1979
+ List of uncovered assertions with their parent requirement context.
267
1980
  """
268
- Analyze requirement structure.
1981
+ return _get_uncovered_assertions(_state["graph"], req_id)
1982
+
1983
+ @mcp.tool()
1984
+ def find_assertions_by_keywords(
1985
+ keywords: list[str],
1986
+ match_all: bool = True,
1987
+ ) -> dict[str, Any]:
1988
+ """Find assertions containing specified keywords.
1989
+
1990
+ Search assertion text for matching keywords.
1991
+ Return assertion id, text, label, and parent requirement context.
1992
+ Complement to existing find_by_keywords() which finds requirements.
269
1993
 
270
1994
  Args:
271
- analysis_type: One of "hierarchy", "orphans", "coverage"
1995
+ keywords: List of keywords to search for.
1996
+ match_all: If True, assertion must contain ALL keywords (AND).
1997
+ If False, assertion must contain ANY keyword (OR).
1998
+
1999
+ Returns:
2000
+ List of matching assertions with their summaries.
272
2001
  """
273
- requirements = ctx.get_requirements()
2002
+ return _find_assertions_by_keywords(_state["graph"], keywords, match_all)
274
2003
 
275
- if analysis_type == "hierarchy":
276
- return _analyze_hierarchy(requirements)
277
- elif analysis_type == "orphans":
278
- return _analyze_orphans(requirements)
279
- elif analysis_type == "coverage":
280
- return _analyze_coverage(requirements)
281
- else:
282
- return {"error": f"Unknown analysis type: {analysis_type}"}
2004
+ # ─────────────────────────────────────────────────────────────────────
2005
+ # File Mutation Tools (REQ-o00063)
2006
+ # ─────────────────────────────────────────────────────────────────────
283
2007
 
2008
+ @mcp.tool()
2009
+ def change_reference_type(
2010
+ req_id: str,
2011
+ target_id: str,
2012
+ new_type: str,
2013
+ save_branch: bool = False,
2014
+ ) -> dict[str, Any]:
2015
+ """Change a reference type in a spec file.
284
2016
 
285
- def _analyze_hierarchy(requirements: Dict[str, Any]) -> Dict[str, Any]:
286
- """Analyze requirement hierarchy."""
287
- # Build parent -> children mapping
288
- children_map: Dict[str, List[str]] = {}
289
- roots = []
2017
+ Modifies Implements/Refines relationships in spec files on disk.
2018
+ Optionally creates a safety branch for rollback.
290
2019
 
291
- for req in requirements.values():
292
- if not req.implements:
293
- roots.append(req.id)
294
- else:
295
- for parent_id in req.implements:
296
- if parent_id not in children_map:
297
- children_map[parent_id] = []
298
- children_map[parent_id].append(req.id)
2020
+ Args:
2021
+ req_id: ID of the requirement to modify.
2022
+ target_id: ID of the target requirement being referenced.
2023
+ new_type: New reference type ('IMPLEMENTS' or 'REFINES').
2024
+ save_branch: If True, create a safety branch before modifying.
299
2025
 
300
- return {
301
- "total": len(requirements),
302
- "roots": roots,
303
- "children_map": children_map,
304
- }
2026
+ Returns:
2027
+ Success status and optional safety_branch name.
2028
+ """
2029
+ result = _change_reference_type(
2030
+ _state["working_dir"], req_id, target_id, new_type, save_branch
2031
+ )
2032
+ # REQ-o00063-F: Refresh graph after file mutations
2033
+ if result.get("success"):
2034
+ new_result, new_graph = _refresh_graph(_state["working_dir"])
2035
+ _state["graph"] = new_graph
2036
+ return result
305
2037
 
2038
+ @mcp.tool()
2039
+ def move_requirement(
2040
+ req_id: str,
2041
+ target_file: str,
2042
+ save_branch: bool = False,
2043
+ ) -> dict[str, Any]:
2044
+ """Move a requirement to a different spec file.
306
2045
 
307
- def _analyze_orphans(requirements: Dict[str, Any]) -> Dict[str, Any]:
308
- """Find orphaned requirements."""
309
- all_ids = set(requirements.keys())
310
- orphans = []
2046
+ Relocates a requirement from its current file to the target file.
2047
+ Optionally creates a safety branch for rollback.
311
2048
 
312
- for req in requirements.values():
313
- for parent_id in req.implements:
314
- if parent_id not in all_ids:
315
- orphans.append(
316
- {
317
- "id": req.id,
318
- "missing_parent": parent_id,
319
- }
320
- )
2049
+ Args:
2050
+ req_id: ID of the requirement to move.
2051
+ target_file: Relative path to the target file (e.g., 'spec/other.md').
2052
+ save_branch: If True, create a safety branch before modifying.
321
2053
 
322
- return {
323
- "count": len(orphans),
324
- "orphans": orphans,
325
- }
2054
+ Returns:
2055
+ Success status and optional safety_branch name.
2056
+ """
2057
+ result = _move_requirement(_state["working_dir"], req_id, target_file, save_branch)
2058
+ # REQ-o00063-F: Refresh graph after file mutations
2059
+ if result.get("success"):
2060
+ new_result, new_graph = _refresh_graph(_state["working_dir"])
2061
+ _state["graph"] = new_graph
2062
+ return result
326
2063
 
2064
+ @mcp.tool()
2065
+ def restore_from_safety_branch(branch_name: str) -> dict[str, Any]:
2066
+ """Restore spec files from a safety branch.
327
2067
 
328
- def _analyze_coverage(requirements: Dict[str, Any]) -> Dict[str, Any]:
329
- """Analyze requirement coverage by level."""
330
- levels: Dict[str, int] = {}
2068
+ Reverts file changes by restoring from a previously created safety branch.
331
2069
 
332
- for req in requirements.values():
333
- level = req.level.upper()
334
- levels[level] = levels.get(level, 0) + 1
2070
+ Args:
2071
+ branch_name: Name of the safety branch to restore from.
335
2072
 
336
- return {
337
- "total": len(requirements),
338
- "by_level": levels,
339
- }
2073
+ Returns:
2074
+ Success status and list of files restored.
2075
+ """
2076
+ result = _restore_from_safety_branch(_state["working_dir"], branch_name)
2077
+ # REQ-o00063-F: Refresh graph after file mutations
2078
+ if result.get("success"):
2079
+ new_result, new_graph = _refresh_graph(_state["working_dir"])
2080
+ _state["graph"] = new_graph
2081
+ return result
2082
+
2083
+ @mcp.tool()
2084
+ def list_safety_branches() -> dict[str, Any]:
2085
+ """List all safety branches.
2086
+
2087
+ Returns available safety branches that can be used with restore_from_safety_branch.
2088
+
2089
+ Returns:
2090
+ List of branch names and count.
2091
+ """
2092
+ return _list_safety_branches_impl(_state["working_dir"])
2093
+
2094
+ return mcp
340
2095
 
341
2096
 
342
2097
  def run_server(
343
- working_dir: Optional[Path] = None,
2098
+ working_dir: Path | None = None,
344
2099
  transport: str = "stdio",
345
2100
  ) -> None:
346
- """
347
- Run the MCP server.
2101
+ """Run the MCP server.
348
2102
 
349
2103
  Args:
350
- working_dir: Working directory
351
- transport: Transport type - "stdio", "sse", or "streamable-http"
2104
+ working_dir: Working directory for graph building.
2105
+ transport: Transport type ('stdio' or 'sse').
352
2106
  """
353
- mcp = create_server(working_dir)
2107
+ mcp = create_server(working_dir=working_dir)
354
2108
  mcp.run(transport=transport)