ref-agents 1.0.0__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 (175) hide show
  1. ref_agents/__init__.py +9 -0
  2. ref_agents/api_keys.json.example +8 -0
  3. ref_agents/auth.py +129 -0
  4. ref_agents/codemap/..md +62 -0
  5. ref_agents/codemap/CODE_MAP.md +37 -0
  6. ref_agents/codemap/core.md +43 -0
  7. ref_agents/codemap/models.md +43 -0
  8. ref_agents/codemap/prompts.md +40 -0
  9. ref_agents/codemap/security.md +45 -0
  10. ref_agents/codemap/tools.md +94 -0
  11. ref_agents/codemap/tools_browser.md +44 -0
  12. ref_agents/codemap/utils.md +42 -0
  13. ref_agents/codemap/workflow.md +42 -0
  14. ref_agents/config/ai_patterns.yaml +101 -0
  15. ref_agents/config/frameworks/angular.yaml +104 -0
  16. ref_agents/config/frameworks/aspnet.yaml +84 -0
  17. ref_agents/config/frameworks/ef_core.yaml +81 -0
  18. ref_agents/config/frameworks/react.yaml +111 -0
  19. ref_agents/config/frameworks/spring_boot.yaml +117 -0
  20. ref_agents/config/languages/csharp.yaml +153 -0
  21. ref_agents/config/languages/java.yaml +188 -0
  22. ref_agents/config/languages/javascript.yaml +172 -0
  23. ref_agents/config/languages/python.yaml +153 -0
  24. ref_agents/config/languages/typescript.yaml +193 -0
  25. ref_agents/constants.py +553 -0
  26. ref_agents/core/__init__.py +15 -0
  27. ref_agents/core/config_loader.py +160 -0
  28. ref_agents/core/config_models.py +167 -0
  29. ref_agents/core/config_parsing.py +84 -0
  30. ref_agents/core/language_detector.py +388 -0
  31. ref_agents/core/validation_models.py +66 -0
  32. ref_agents/core/validation_primitives.py +176 -0
  33. ref_agents/errors.py +34 -0
  34. ref_agents/license_client.py +247 -0
  35. ref_agents/models/__init__.py +22 -0
  36. ref_agents/models/gherkin.py +45 -0
  37. ref_agents/models/hierarchy.py +80 -0
  38. ref_agents/models/invest.py +59 -0
  39. ref_agents/models/version.py +49 -0
  40. ref_agents/prompts/__init__.py +9 -0
  41. ref_agents/prompts/start_agent.py +772 -0
  42. ref_agents/rules/architecture/backend_patterns.md +43 -0
  43. ref_agents/rules/architecture/diagramming.md +100 -0
  44. ref_agents/rules/architecture/frontend_patterns.md +40 -0
  45. ref_agents/rules/architecture/impact_analysis.md +129 -0
  46. ref_agents/rules/architecture/migration_strategy.md +208 -0
  47. ref_agents/rules/architecture/regression_protocol.md +77 -0
  48. ref_agents/rules/architecture/system_design.md +97 -0
  49. ref_agents/rules/common/codemap_standard.md +97 -0
  50. ref_agents/rules/common/core_protocol.md +59 -0
  51. ref_agents/rules/common/prompt_engineering.md +294 -0
  52. ref_agents/rules/development/debugging.md +32 -0
  53. ref_agents/rules/development/implementation.md +205 -0
  54. ref_agents/rules/operations/completion.md +119 -0
  55. ref_agents/rules/operations/cutover_protocol.md +218 -0
  56. ref_agents/rules/operations/discovery.md +179 -0
  57. ref_agents/rules/operations/fix_workflow.md +87 -0
  58. ref_agents/rules/operations/forensics.md +278 -0
  59. ref_agents/rules/operations/platform.md +263 -0
  60. ref_agents/rules/operations/synchronous_flow.md +25 -0
  61. ref_agents/rules/product/ac_validation.md +25 -0
  62. ref_agents/rules/product/brainstorming.md +27 -0
  63. ref_agents/rules/product/ref_flow.md +101 -0
  64. ref_agents/rules/product/requirements_std.md +114 -0
  65. ref_agents/rules/product/spec_writing.md +235 -0
  66. ref_agents/rules/product/strategy.md +96 -0
  67. ref_agents/rules/quality/documentation_standards.md +46 -0
  68. ref_agents/rules/quality/parity_testing.md +234 -0
  69. ref_agents/rules/quality/project_documentation.md +56 -0
  70. ref_agents/rules/quality/qa_lead.md +111 -0
  71. ref_agents/rules/quality/test_design.md +146 -0
  72. ref_agents/rules/quality/testing_standards.md +293 -0
  73. ref_agents/rules/review/pr_review.md +116 -0
  74. ref_agents/rules/security/security_audit.md +83 -0
  75. ref_agents/security/__init__.py +22 -0
  76. ref_agents/security/dependency_audit.py +188 -0
  77. ref_agents/security/file_audit.py +208 -0
  78. ref_agents/security/network_scan.py +179 -0
  79. ref_agents/security/report_generator.py +313 -0
  80. ref_agents/security/secret_scan.py +252 -0
  81. ref_agents/security/url_scan.py +240 -0
  82. ref_agents/security_scan.py +236 -0
  83. ref_agents/server.py +1586 -0
  84. ref_agents/session.py +100 -0
  85. ref_agents/tool_names.py +55 -0
  86. ref_agents/tools/__init__.py +8 -0
  87. ref_agents/tools/agents_generator.py +315 -0
  88. ref_agents/tools/ai_pattern_detector.py +815 -0
  89. ref_agents/tools/brownfield_populator.py +529 -0
  90. ref_agents/tools/browser/__init__.py +50 -0
  91. ref_agents/tools/browser/evidence_verifier.py +302 -0
  92. ref_agents/tools/browser/execution_logger.py +249 -0
  93. ref_agents/tools/browser/playwright_mcp_client.py +259 -0
  94. ref_agents/tools/browser/screenshot_utils.py +184 -0
  95. ref_agents/tools/browser/test_executor.py +537 -0
  96. ref_agents/tools/code_quality_scanner.py +629 -0
  97. ref_agents/tools/codemap/..md +93 -0
  98. ref_agents/tools/codemap/CODE_MAP.md +30 -0
  99. ref_agents/tools/codemap/browser.md +44 -0
  100. ref_agents/tools/codemap.py +403 -0
  101. ref_agents/tools/codemap_freshness.py +234 -0
  102. ref_agents/tools/comment_smell_scanner.py +346 -0
  103. ref_agents/tools/complexity.py +436 -0
  104. ref_agents/tools/complexity_ast.py +333 -0
  105. ref_agents/tools/compliance.py +246 -0
  106. ref_agents/tools/compliance_remediation.py +846 -0
  107. ref_agents/tools/context_graph.py +839 -0
  108. ref_agents/tools/context_manager.py +550 -0
  109. ref_agents/tools/context_tools.py +121 -0
  110. ref_agents/tools/cross_repo_linker.py +393 -0
  111. ref_agents/tools/dead_code_scanner.py +637 -0
  112. ref_agents/tools/debt_scanner.py +1092 -0
  113. ref_agents/tools/dependency_graph.py +272 -0
  114. ref_agents/tools/discovery_audit.py +372 -0
  115. ref_agents/tools/docs_scanner.py +600 -0
  116. ref_agents/tools/evaluate_gate.py +119 -0
  117. ref_agents/tools/external_detector.py +524 -0
  118. ref_agents/tools/features_generator.py +282 -0
  119. ref_agents/tools/flow_gap_detector.py +373 -0
  120. ref_agents/tools/flow_mapper.py +327 -0
  121. ref_agents/tools/full_suite_runner.py +740 -0
  122. ref_agents/tools/gherkin_parser.py +227 -0
  123. ref_agents/tools/guard_tools.py +139 -0
  124. ref_agents/tools/handoff_tools.py +282 -0
  125. ref_agents/tools/health_scanner.py +1211 -0
  126. ref_agents/tools/hierarchy_manager.py +289 -0
  127. ref_agents/tools/invest_scorer.py +249 -0
  128. ref_agents/tools/jira_confluence_export.py +306 -0
  129. ref_agents/tools/json_output.py +76 -0
  130. ref_agents/tools/migration_mapper.py +946 -0
  131. ref_agents/tools/migration_readiness_scanner.py +209 -0
  132. ref_agents/tools/pattern_learner.py +522 -0
  133. ref_agents/tools/report_utils.py +155 -0
  134. ref_agents/tools/requirements_serializer.py +225 -0
  135. ref_agents/tools/security_audit_tool.py +106 -0
  136. ref_agents/tools/sequencing_engine.py +288 -0
  137. ref_agents/tools/summary_generator.py +275 -0
  138. ref_agents/tools/symbol_resolver.py +306 -0
  139. ref_agents/tools/symbol_smoke_runner.py +336 -0
  140. ref_agents/tools/test_plan_validator.py +189 -0
  141. ref_agents/tools/test_smell_walker.py +902 -0
  142. ref_agents/tools/tier1_fixer.py +502 -0
  143. ref_agents/tools/validators/__init__.py +419 -0
  144. ref_agents/tools/validators/architect.py +268 -0
  145. ref_agents/tools/validators/cutover_engineer.py +167 -0
  146. ref_agents/tools/validators/developer.py +180 -0
  147. ref_agents/tools/validators/discovery.py +150 -0
  148. ref_agents/tools/validators/forensic_engineer.py +191 -0
  149. ref_agents/tools/validators/impact_architect.py +181 -0
  150. ref_agents/tools/validators/migration_planner.py +166 -0
  151. ref_agents/tools/validators/parity_tester.py +155 -0
  152. ref_agents/tools/validators/platform_engineer.py +134 -0
  153. ref_agents/tools/validators/pr_reviewer.py +129 -0
  154. ref_agents/tools/validators/product_manager.py +291 -0
  155. ref_agents/tools/validators/qa_lead.py +172 -0
  156. ref_agents/tools/validators/scrum_master.py +212 -0
  157. ref_agents/tools/validators/security_owner.py +162 -0
  158. ref_agents/tools/validators/specifier.py +134 -0
  159. ref_agents/tools/validators/strategist.py +149 -0
  160. ref_agents/tools/validators/tester.py +121 -0
  161. ref_agents/tools/version_manager.py +202 -0
  162. ref_agents/tools/workflow_tools.py +1549 -0
  163. ref_agents/utils/__init__.py +21 -0
  164. ref_agents/utils/git_utils.py +351 -0
  165. ref_agents/utils/handoff_logger.py +368 -0
  166. ref_agents/utils/ignore_matcher.py +270 -0
  167. ref_agents/workflow/__init__.py +19 -0
  168. ref_agents/workflow/capabilities.py +328 -0
  169. ref_agents/workflow/state_machine.py +708 -0
  170. ref_agents/workflow/transitions.py +658 -0
  171. ref_agents-1.0.0.dist-info/METADATA +365 -0
  172. ref_agents-1.0.0.dist-info/RECORD +175 -0
  173. ref_agents-1.0.0.dist-info/WHEEL +4 -0
  174. ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
  175. ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
ref_agents/server.py ADDED
@@ -0,0 +1,1586 @@
1
+ """REF Agents Server.
2
+
3
+ REF Agents is the AI-powered guidance layer for the Relay Engineering Framework (REF).
4
+ Built on Anthropic's Model Context Protocol (MCP) for seamless AI assistant integration.
5
+
6
+ This module defines the FastMCP server exposing REF agents, prompts, and tools.
7
+ It manages session state for the active agent and provides tools for code compliance
8
+ and codebase mapping.
9
+ """
10
+
11
+ import re
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Literal, cast
15
+
16
+ import structlog
17
+ from fastmcp import FastMCP
18
+
19
+ from ref_agents.auth import create_api_key_verifier
20
+ from ref_agents.license_client import get_status, upgrade_message
21
+ from ref_agents.constants import (
22
+ AGENT_CAPABILITIES,
23
+ AVAILABLE_AGENTS,
24
+ STORY_REQUIRED_AGENTS,
25
+ Status,
26
+ )
27
+ from ref_agents.prompts import start_agent
28
+ from ref_agents.session import SessionManager
29
+ from ref_agents.tool_names import TOOL_AGENT_ACTIVATE
30
+ from ref_agents.tools import (
31
+ ai_pattern_detector,
32
+ codemap_freshness,
33
+ complexity,
34
+ context_manager,
35
+ context_tools,
36
+ cross_repo_linker,
37
+ dead_code_scanner,
38
+ debt_scanner,
39
+ dependency_graph,
40
+ docs_scanner,
41
+ external_detector,
42
+ flow_gap_detector,
43
+ flow_mapper,
44
+ guard_tools,
45
+ handoff_tools,
46
+ health_scanner,
47
+ hierarchy_manager,
48
+ migration_readiness_scanner,
49
+ pattern_learner,
50
+ security_audit_tool,
51
+ sequencing_engine,
52
+ summary_generator,
53
+ workflow_tools,
54
+ )
55
+ from ref_agents.utils import handoff_logger
56
+ from ref_agents.workflow.state_machine import WorkflowManager
57
+
58
+ # Setup - Route logs to stderr (stdout reserved for MCP JSON-RPC)
59
+ structlog.configure(
60
+ processors=[structlog.processors.JSONRenderer()],
61
+ wrapper_class=structlog.make_filtering_bound_logger(30), # WARNING+
62
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
63
+ )
64
+ logger = structlog.get_logger(__name__)
65
+
66
+ # Module-level active agent tracking (also accessible via SessionManager)
67
+ ACTIVE_AGENT: str | None = None
68
+
69
+
70
+ auth_verifier = create_api_key_verifier()
71
+ # auth_verifier is always returned (passthrough when no keys, real auth when keys configured)
72
+ mcp = FastMCP("REF-Agents", auth=auth_verifier)
73
+
74
+
75
+ # ── Prompt gate ──────────────────────────────────────────────────────────────
76
+ # All persona prompts require a paid REF subscription.
77
+ # Free tier: scanners + workflow tools remain ungated.
78
+
79
+
80
+ def _require_paid(agent_name: str) -> str | None:
81
+ """Return upgrade message if not paid, None if access granted.
82
+
83
+ Args:
84
+ agent_name: Display name of the requested agent.
85
+
86
+ Returns:
87
+ Upgrade message string if free tier, None if paid access granted.
88
+ """
89
+ if not get_status().is_paid:
90
+ return upgrade_message(f"{agent_name} agent")
91
+ return None
92
+
93
+
94
+ # --- Prompts (Agents) ---
95
+ @mcp.prompt("activate_ref")
96
+ def activate_ref_prompt() -> str:
97
+ """General entry point. Use this to start the REF session."""
98
+ gate = _require_paid("activate_ref")
99
+ if gate:
100
+ return gate
101
+ return context_tools._format_available_agents_numbered()
102
+
103
+
104
+ @mcp.prompt("product_manager")
105
+ def product_manager_prompt() -> str:
106
+ return _require_paid("Product Manager") or start_agent.get_product_manager_prompt()
107
+
108
+
109
+ @mcp.prompt("architect")
110
+ def architect_prompt() -> str:
111
+ return _require_paid("Architect") or start_agent.get_architect_prompt()
112
+
113
+
114
+ @mcp.prompt("developer")
115
+ def developer_prompt() -> str:
116
+ return _require_paid("Developer") or start_agent.get_developer_prompt()
117
+
118
+
119
+ @mcp.prompt("tester")
120
+ def tester_prompt() -> str:
121
+ return _require_paid("Tester") or start_agent.get_tester_prompt()
122
+
123
+
124
+ @mcp.prompt("scrum_master")
125
+ def scrum_master_prompt() -> str:
126
+ return _require_paid("Scrum Master") or start_agent.get_scrum_master_prompt()
127
+
128
+
129
+ @mcp.prompt("security_owner")
130
+ def security_owner_prompt() -> str:
131
+ return _require_paid("Security Owner") or start_agent.get_security_owner_prompt()
132
+
133
+
134
+ @mcp.prompt("pr_reviewer")
135
+ def pr_reviewer_prompt() -> str:
136
+ return _require_paid("PR Reviewer") or start_agent.get_pr_reviewer_prompt()
137
+
138
+
139
+ @mcp.prompt("discovery")
140
+ def discovery_prompt() -> str:
141
+ return _require_paid("Discovery") or start_agent.get_discovery_prompt()
142
+
143
+
144
+ @mcp.prompt("qa_lead")
145
+ def qa_lead_prompt() -> str:
146
+ return _require_paid("QA Lead") or start_agent.get_qa_lead_prompt()
147
+
148
+
149
+ @mcp.prompt("specifier")
150
+ def specifier_prompt() -> str:
151
+ return _require_paid("Specifier") or start_agent.get_specifier_prompt()
152
+
153
+
154
+ @mcp.prompt("test_designer")
155
+ def test_designer_prompt() -> str:
156
+ return _require_paid("Test Designer") or start_agent.get_test_designer_prompt()
157
+
158
+
159
+ @mcp.prompt("impact_architect")
160
+ def impact_architect_prompt() -> str:
161
+ return (
162
+ _require_paid("Impact Architect") or start_agent.get_impact_architect_prompt() # type: ignore[attr-defined]
163
+ )
164
+
165
+
166
+ @mcp.prompt("forensic_engineer")
167
+ def forensic_engineer_prompt() -> str:
168
+ return (
169
+ _require_paid("Forensic Engineer") or start_agent.get_forensic_engineer_prompt()
170
+ )
171
+
172
+
173
+ @mcp.prompt("strategist")
174
+ def strategist_prompt() -> str:
175
+ return _require_paid("Strategist") or start_agent.get_strategist_prompt()
176
+
177
+
178
+ @mcp.prompt("platform_engineer")
179
+ def platform_engineer_prompt() -> str:
180
+ return (
181
+ _require_paid("Platform Engineer") or start_agent.get_platform_engineer_prompt()
182
+ )
183
+
184
+
185
+ @mcp.prompt("migration_planner")
186
+ def migration_planner_prompt() -> str:
187
+ return (
188
+ _require_paid("Migration Planner") or start_agent.get_migration_planner_prompt()
189
+ )
190
+
191
+
192
+ @mcp.prompt("parity_tester")
193
+ def parity_tester_prompt() -> str:
194
+ return _require_paid("Parity Tester") or start_agent.get_parity_tester_prompt()
195
+
196
+
197
+ @mcp.prompt("cutover_engineer")
198
+ def cutover_engineer_prompt() -> str:
199
+ return (
200
+ _require_paid("Cutover Engineer") or start_agent.get_cutover_engineer_prompt()
201
+ )
202
+
203
+
204
+ # --- Context Tools (standalone) ---
205
+ @mcp.tool()
206
+ def set_project_root(path: str) -> str:
207
+ """Explicitly set the project root directory."""
208
+ return context_tools.set_project_root_impl(path)
209
+
210
+
211
+ # --- Analysis Tools ---
212
+ @mcp.tool()
213
+ def generate_codemap(directory: str) -> str:
214
+ """Scans directory and generates CODE_MAP.md."""
215
+ return context_tools.generate_codemap_wrapper(directory)
216
+
217
+
218
+ # --- Namespaced dispatchers (log.*, scan.*) ---
219
+ LogEventAction = Literal[
220
+ "escalation", "ownership", "replan", "debt", "phase", "artifact"
221
+ ]
222
+ ScanTargetAction = Literal[
223
+ "health",
224
+ "debt",
225
+ "dead_code",
226
+ "complexity",
227
+ "external",
228
+ "regression",
229
+ "docs",
230
+ "ai_patterns",
231
+ "security",
232
+ "codemap_freshness",
233
+ "migration_readiness",
234
+ "full_suite",
235
+ ]
236
+
237
+ # Only health triggers auto-remediation — it is the only scan that writes
238
+ # health_report.json which start_remediation() reads from disk.
239
+ # security/ai_patterns/complexity can be manually remediated via
240
+ # remediate(action="start") after their own scan runs.
241
+ _AUTO_REMEDIATE_TARGETS: frozenset[str] = frozenset({"health"})
242
+
243
+ # Severity counts are embedded in all scanner return strings in this format:
244
+ # "Critical: N | High: N"
245
+ _SEVERITY_PATTERN = re.compile(
246
+ r"\*{0,2}Critical:?\*{0,2}:?\s*(\d+).*?\*{0,2}High:?\*{0,2}:?\s*(\d+)",
247
+ re.IGNORECASE | re.DOTALL,
248
+ )
249
+
250
+
251
+ def _remediation_bootstrap( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
252
+ scan_result: str,
253
+ target: str,
254
+ story_id: str,
255
+ directory: str,
256
+ file_path: str,
257
+ ) -> str:
258
+ """Append remediation next-action block to scan output when violations warrant it.
259
+
260
+ Parses Critical/High counts from the scan result string. Appends a
261
+ remediate(action="start") call only for remediable targets with Critical
262
+ or High violations. Medium/Low violations produce an informational note only.
263
+ Informational targets (health, docs, external, regression) are never bootstrapped.
264
+
265
+ Args:
266
+ scan_result: Raw string returned by the scan tool.
267
+ target: Scan target name (e.g. "health").
268
+ story_id: Active story ID from session context, may be empty.
269
+ directory: Directory parameter passed to scan.
270
+ file_path: File path parameter passed to scan.
271
+
272
+ Returns:
273
+ Scan result string, optionally extended with remediation bootstrap block.
274
+ """
275
+ if target not in _AUTO_REMEDIATE_TARGETS:
276
+ return scan_result
277
+
278
+ match = _SEVERITY_PATTERN.search(scan_result)
279
+ critical = int(match.group(1)) if match else 0
280
+ high = int(match.group(2)) if match else 0
281
+
282
+ if critical == 0 and high == 0:
283
+ # Check for any violations at all (compliance tool uses different format)
284
+ if (
285
+ "⚠️ Issues Found" not in scan_result
286
+ and "issues found" not in scan_result.lower()
287
+ ):
288
+ return scan_result
289
+ # Has violations but couldn't parse severity — treat as high
290
+ high = 1
291
+
292
+ scope = file_path or directory or "."
293
+ sid = story_id or "<story_id>"
294
+
295
+ if critical > 0 or high > 0:
296
+ # Call start_remediation directly — do not leave this to the agent.
297
+ # start_remediation() reads health_report.json and writes a small session
298
+ # file. Fast, synchronous, safe inside MCP tool handler.
299
+ try:
300
+ from ref_agents.tools.compliance_remediation import start_remediation
301
+
302
+ remediation_plan = start_remediation(story_id=sid, directory=scope)
303
+ return (
304
+ scan_result
305
+ + "\n\n---\n"
306
+ + "## ⚡ Remediation Session Started\n"
307
+ + remediation_plan
308
+ )
309
+ except (ValueError, OSError, RuntimeError) as e:
310
+ logger.error("auto_remediation_failed", error=str(e))
311
+ return (
312
+ scan_result
313
+ + "\n\n---\n"
314
+ + f"⚠️ Auto-remediation failed: {e}\n"
315
+ + f'Run manually: remediate(action="start", story_id="{sid}", directory="{scope}")'
316
+ )
317
+ else:
318
+ return (
319
+ scan_result
320
+ + "\n\n---\n"
321
+ + "ℹ️ Medium/Low violations only. No immediate remediation required.\n"
322
+ + f'Log as debt: log(event="debt", debt_type=..., severity="medium", location="{scope}")'
323
+ )
324
+
325
+
326
+ @mcp.tool()
327
+ def log( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
328
+ event: LogEventAction,
329
+ story_id: str = "",
330
+ level: str = "",
331
+ reason: str = "",
332
+ to_agents: str = "",
333
+ to_owner: str = "",
334
+ trigger: str = "",
335
+ impact: str = "",
336
+ debt_type: str = "",
337
+ severity: str = "",
338
+ location: str = "",
339
+ phase: str = "",
340
+ outcome: str = "",
341
+ next_phase: str = "",
342
+ artifact: str = "",
343
+ ) -> str:
344
+ """Audit logging. event: escalation | ownership | replan | debt | phase | artifact.
345
+ escalation: level, reason, to_agents. ownership: to_owner, reason.
346
+ replan: trigger, impact. debt: debt_type, severity, location.
347
+ phase: phase, outcome, next_phase. artifact: artifact name (with story_id)."""
348
+ if event == "artifact":
349
+ return workflow_tools.mark_artifact_complete(
350
+ story_id=story_id, artifact=artifact
351
+ )
352
+ if event == "phase":
353
+ # G1 gate: block phase close if docs/ produces artifacts are missing.
354
+ # Applies only when outcome is "passed" — failures/skips bypass the gate.
355
+ if outcome == "passed" and story_id and phase:
356
+ from ref_agents.constants import PHASE_CONFIG
357
+
358
+ phase_key = next(
359
+ (k for k, v in PHASE_CONFIG.items() if v.get("primary_agent") == phase),
360
+ None,
361
+ )
362
+ if phase_key is None:
363
+ # Try matching by phase name string directly
364
+ phase_key = phase if phase in PHASE_CONFIG else None
365
+ if phase_key:
366
+ blocked = workflow_tools._check_produces_artifacts(story_id, phase_key)
367
+ if blocked:
368
+ return blocked
369
+
370
+ # G4 (STORY-021..038): validator gate via shared phase_gate helper.
371
+ # Blocks phase-pass log when registered validator returns non-PASS.
372
+ # Gap 12: outcome="skipped" allowed but requires explicit reason text.
373
+ if outcome == "skipped" and not reason:
374
+ return (
375
+ f"{Status.ERROR} outcome='skipped' requires `reason=...` "
376
+ "explaining why this phase was bypassed."
377
+ )
378
+ if outcome == "passed" and phase:
379
+ try:
380
+ from ref_agents.tools.validators import phase_gate
381
+
382
+ allowed, msg, _report = phase_gate(role=phase, story_id=story_id)
383
+ if not allowed:
384
+ return msg
385
+ except Exception as _exc: # noqa: BLE001 — gate must not crash logging
386
+ logger.warning("validator_gate_failed", phase=phase, error=str(_exc))
387
+
388
+ # G3: auto-run audit_discovery after discovery phase passes.
389
+ if outcome == "passed" and phase == "discovery" and story_id:
390
+ try:
391
+ from ref_agents.session import SessionManager
392
+ from ref_agents.tools.discovery_audit import audit_discovery
393
+
394
+ root = str(SessionManager.get().project_root or ".")
395
+ audit_result = audit_discovery(root)
396
+ if "❌" in audit_result or "FAIL" in audit_result:
397
+ return (
398
+ f"{Status.ERROR} Discovery phase close blocked: audit_discovery failed.\n\n"
399
+ f"{audit_result}\n\n"
400
+ f"Fix all ❌ items above, then re-call:\n"
401
+ f' log(event="phase", story_id="{story_id}", phase="discovery", outcome="passed")'
402
+ )
403
+ except (ValueError, OSError) as _e:
404
+ logger.warning("discovery_audit_auto_failed", error=str(_e))
405
+
406
+ workflow_tools._auto_capture_decision(
407
+ story_id=story_id,
408
+ phase=phase,
409
+ summary=f"[{outcome.upper()}] {phase} → {next_phase or 'blocked'}",
410
+ )
411
+ elif event in ("escalation", "replan"):
412
+ _event_text = reason[:100] if reason else (trigger[:100] if trigger else "")
413
+ workflow_tools._auto_capture_decision( # type: ignore[call-arg]
414
+ story_id=story_id,
415
+ phase=event,
416
+ summary=f"{event.title()}: {_event_text}",
417
+ node_type="issue",
418
+ )
419
+ return handoff_tools.log_event(
420
+ event,
421
+ story_id=story_id,
422
+ level=level,
423
+ reason=reason,
424
+ to_agents=to_agents,
425
+ to_owner=to_owner,
426
+ trigger=trigger,
427
+ impact=impact,
428
+ debt_type=debt_type,
429
+ severity=severity,
430
+ location=location,
431
+ phase=phase,
432
+ outcome=outcome,
433
+ next_phase=next_phase,
434
+ )
435
+
436
+
437
+ def _run_migration_detection(directory: str, mode: str) -> None:
438
+ """Run migration intent detection after health scan completes.
439
+
440
+ Reads ref-reports/platform_engineer/health_report.json (written by scan_health).
441
+ If migration intent detected or mode="migration", generates MIGRATION_MAP.md
442
+ and ref-reports/migration_map.json, and sets session migration mode.
443
+
444
+ Args:
445
+ directory: Project root directory.
446
+ mode: Scan mode string — "migration" activates explicit override.
447
+ """
448
+ import os
449
+ from pathlib import Path as _Path
450
+
451
+ explicit = mode == "migration" or os.environ.get("REF_MIGRATION_MODE") == "1"
452
+
453
+ try:
454
+ from ref_agents.tools.migration_mapper import (
455
+ MigrationMapper,
456
+ detect_migration_intent,
457
+ )
458
+ except ImportError:
459
+ return
460
+
461
+ try:
462
+ intent = detect_migration_intent(_Path(directory), explicit=explicit)
463
+
464
+ if intent.is_migration:
465
+ SessionManager.get().set_migration_mode(True)
466
+ mapper = MigrationMapper(_Path(directory))
467
+ assessments = mapper.assess_modules()
468
+ mapper.generate_map(intent, assessments)
469
+ except Exception as exc: # noqa: BLE001 # migration detection must not crash MCP session — best-effort feature, mapper internals are third-party/dynamic
470
+ import structlog as _structlog
471
+
472
+ _structlog.get_logger(__name__).warning(
473
+ "migration_detection_failed", error=str(exc), directory=directory
474
+ )
475
+
476
+
477
+ @mcp.tool()
478
+ def scan( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
479
+ target: ScanTargetAction,
480
+ directory: str = "",
481
+ files: str = "",
482
+ file_path: str = "",
483
+ summary: bool = False,
484
+ mode: str = "",
485
+ ) -> str:
486
+ """Codebase analysis. target: health | debt | dead_code | complexity | external
487
+ | regression | docs | ai_patterns | security | codemap_freshness.
488
+ health/debt/dead_code/docs/security/codemap_freshness use directory.
489
+ complexity/regression use files. compliance/ai_patterns use file_path.
490
+ health is the primary entry point — runs all scanners and triggers remediation.
491
+ mode: pass "migration" with target="health" to activate migration intent detection."""
492
+ dispatch = {
493
+ "health": lambda: health_scanner.scan_health(directory or "."),
494
+ "debt": lambda: debt_scanner.scan_debt_tool(directory or ".", summary),
495
+ "dead_code": lambda: dead_code_scanner.scan_dead_code(
496
+ directory or ".", summary
497
+ ),
498
+ "complexity": lambda: complexity.scan_complexity_tool(files),
499
+ "external": lambda: external_detector.scan_and_report(directory or "."),
500
+ "regression": lambda: workflow_tools.scan_regression_risk_tool(files),
501
+ "docs": lambda: docs_scanner.validate_docs_tool(directory or "."),
502
+ "ai_patterns": lambda: ai_pattern_detector.detect_ai_patterns_tool(
503
+ file_path or directory or "."
504
+ ),
505
+ "security": lambda: security_audit_tool.run_security_audit(directory or ""),
506
+ "codemap_freshness": lambda: codemap_freshness.scan_codemap_freshness(
507
+ directory or "."
508
+ ),
509
+ "migration_readiness": lambda: migration_readiness_scanner.scan_migration_readiness(
510
+ directory or "."
511
+ ),
512
+ # STORY-TQ-002 full-suite execution gate. Server-side subprocess driver.
513
+ "full_suite": lambda: __import__(
514
+ "ref_agents.tools.full_suite_runner", fromlist=["run_full_suite"]
515
+ ).run_full_suite(directory or "."),
516
+ }
517
+ fn = dispatch.get(target)
518
+ if not fn:
519
+ return f"{Status.ERROR} Unknown scan target: {target}. Use: {list(dispatch.keys())}"
520
+
521
+ result = fn()
522
+
523
+ # Codemap auto-regeneration: health scan is the mandatory pre-completion gate,
524
+ # so regenerating here ensures codemap reflects every change that passed health checks.
525
+ if target == "health":
526
+ try:
527
+ from ref_agents.tools.workflow_tools import (
528
+ _auto_regenerate_codemap, # type: ignore[attr-defined]
529
+ )
530
+
531
+ _auto_regenerate_codemap()
532
+ except (ImportError, OSError, RuntimeError):
533
+ pass
534
+
535
+ # Migration intent detection: runs after health scan writes health_report.json.
536
+ # Activated by mode="migration" param, REF_MIGRATION_MODE=1 env var, or auto-detection.
537
+ _run_migration_detection(directory or ".", mode)
538
+
539
+ # Retrieve story_id from active workflow state for remediation bootstrap (best-effort)
540
+ try:
541
+ from ref_agents.workflow.state_machine import WorkflowManager
542
+
543
+ story_id = WorkflowManager().get_active_story() or ""
544
+ except (ImportError, OSError, AttributeError):
545
+ story_id = ""
546
+
547
+ return _remediation_bootstrap(
548
+ scan_result=result,
549
+ target=target,
550
+ story_id=story_id,
551
+ directory=directory,
552
+ file_path=file_path,
553
+ )
554
+
555
+
556
+ # --- workflow.* ---
557
+ WorkflowAction = Literal[
558
+ "init",
559
+ "list",
560
+ "state",
561
+ "next",
562
+ "parallel_start",
563
+ "parallel_complete",
564
+ "fix_flow",
565
+ "complete_fix_flow",
566
+ "complete",
567
+ "validate_story_file",
568
+ "validate_and_advance",
569
+ ]
570
+
571
+
572
+ def _workflow_fix_flow(
573
+ story_id: str, file_path: str, line_number: str, error_description: str
574
+ ) -> str:
575
+ """Start a fix-flow session and activate the forensic_engineer agent."""
576
+ global ACTIVE_AGENT
577
+ sid = (
578
+ story_id
579
+ or SessionManager.get().active_story
580
+ or WorkflowManager().get_active_story()
581
+ or "<story_id>"
582
+ )
583
+ result = workflow_tools.start_fix_flow(
584
+ story_id=sid,
585
+ file_path=file_path,
586
+ line_number=line_number,
587
+ error_description=error_description,
588
+ )
589
+ if result["error"]:
590
+ return f"{Status.ERROR} Fix flow failed: {result['error']}"
591
+ ACTIVE_AGENT = "forensic_engineer"
592
+ SessionManager.get().active_agent = "forensic_engineer"
593
+ handoff_logger.log_agent_activation("forensic_engineer")
594
+ logger.info("fix_flow_agent_activated", session_id=result["session_id"])
595
+ return (
596
+ f"{Status.SUCCESS} Fix Flow {result['session_id']} started.\n\n"
597
+ + result["forensic_prompt"]
598
+ )
599
+
600
+
601
+ def _workflow_complete_fix_flow(story_id: str) -> str:
602
+ """Close a fix-flow session and activate the scrum_master agent."""
603
+ global ACTIVE_AGENT
604
+ sid = (
605
+ story_id
606
+ or SessionManager.get().active_story
607
+ or WorkflowManager().get_active_story()
608
+ or "<story_id>"
609
+ )
610
+ result = workflow_tools.complete_fix_flow(
611
+ session_id=story_id,
612
+ story_id=sid,
613
+ outcome="passed",
614
+ )
615
+ if result["error"]:
616
+ return f"{Status.ERROR} Fix flow close failed: {result['error']}"
617
+ ACTIVE_AGENT = "scrum_master"
618
+ SessionManager.get().active_agent = "scrum_master"
619
+ handoff_logger.log_agent_activation("scrum_master")
620
+ logger.info("fix_flow_scrum_master_activated")
621
+ return result["scrum_master_prompt"]
622
+
623
+
624
+ @mcp.tool()
625
+ def workflow( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
626
+ action: WorkflowAction,
627
+ story_id: str = "",
628
+ epic_id: str = "",
629
+ agent: str = "",
630
+ status: str = "passed",
631
+ notification: str = "",
632
+ completed_task: str = "",
633
+ file_path: str = "",
634
+ line_number: str = "",
635
+ error_description: str = "",
636
+ requirements_path: str = "",
637
+ next_phase: str = "",
638
+ artifact_path: str = "",
639
+ ) -> str:
640
+ """Workflow state machine. action: init | state | next | parallel_start | parallel_complete | fix_flow | complete_fix_flow | complete | validate_and_advance."""
641
+ _workflow_dispatch: dict[str, Callable[[], str]] = {
642
+ "init": lambda: workflow_tools.init_workflow(
643
+ story_id, epic_id, requirements_path
644
+ ),
645
+ "list": lambda: workflow_tools.list_stories(),
646
+ "state": lambda: workflow_tools.get_workflow_state(story_id),
647
+ "next": lambda: workflow_tools.suggest_next_agent(story_id, completed_task),
648
+ "parallel_start": lambda: workflow_tools.start_parallel_validation(story_id),
649
+ "parallel_complete": lambda: workflow_tools.complete_parallel_task(
650
+ story_id, agent, status, notification
651
+ ),
652
+ "complete": lambda: workflow_tools.close_workflow(story_id),
653
+ "fix_flow": lambda: _workflow_fix_flow(
654
+ story_id, file_path, line_number, error_description
655
+ ),
656
+ "complete_fix_flow": lambda: _workflow_complete_fix_flow(story_id),
657
+ "validate_story_file": lambda: workflow_tools.validate_story_file(story_id),
658
+ "validate_and_advance": lambda: workflow_tools.validate_and_advance(
659
+ story_id, next_phase, artifact_path
660
+ ),
661
+ }
662
+ handler = _workflow_dispatch.get(action)
663
+ if handler is None:
664
+ return f"{Status.ERROR} Unknown workflow action: {action!r}. Valid: {list(_workflow_dispatch)}"
665
+ return handler()
666
+
667
+
668
+ # --- validate.* (STORY-021) ---
669
+
670
+
671
+ @mcp.tool()
672
+ def validate(role: str, artifact_id: str = "", artifact_path: str = "") -> str:
673
+ """Validate a role's output artifact.
674
+
675
+ Args:
676
+ role: Role name (e.g., "product_manager", "architect").
677
+ artifact_id: Story / artifact identifier.
678
+ artifact_path: Optional explicit path to artifact on disk.
679
+
680
+ Returns:
681
+ JSON-formatted ValidationReport.
682
+ """
683
+ import json as _json
684
+
685
+ from ref_agents.tools.validators import validate as _dispatch
686
+
687
+ report = _dispatch(role=role, artifact_id=artifact_id, artifact_path=artifact_path)
688
+ return _json.dumps(report.model_dump(mode="json"), indent=2)
689
+
690
+
691
+ # --- agent.* ---
692
+ AgentAction = Literal["list", "activate", "identify", "check"]
693
+
694
+
695
+ def _set_active_agent_impl(agent_name: str) -> str:
696
+ """Validate, check prerequisites, set session, return prompt."""
697
+ target_agent = agent_name
698
+ if agent_name.isdigit():
699
+ agent_num = int(agent_name)
700
+ agents_list = list(AVAILABLE_AGENTS.keys())
701
+ if 1 <= agent_num <= len(agents_list):
702
+ target_agent = agents_list[agent_num - 1]
703
+ else:
704
+ return f"{Status.ERROR} Invalid number `{agent_name}`. Select 1-{len(agents_list)}."
705
+
706
+ if target_agent not in AVAILABLE_AGENTS:
707
+ valid_agents = ", ".join(AVAILABLE_AGENTS.keys())
708
+ return f"{Status.ERROR} Invalid agent `{target_agent}`. Valid agents: {valid_agents}"
709
+
710
+ if target_agent in ["product_manager", "architect", "developer", "pr_reviewer"]:
711
+ if not guard_tools._check_codemap_exists():
712
+ handoff_logger.log_escalation(
713
+ level="L1",
714
+ from_agent=target_agent,
715
+ to_agents=["discovery"],
716
+ reason="CODE_MAP.md missing - brownfield prerequisite not met",
717
+ )
718
+ return (
719
+ f"🔄 Cannot activate `{target_agent}`. CODE_MAP.md not found.\n\n"
720
+ "**This agent requires codebase context.**\n\n"
721
+ "**Option 1 - Set project path** (if CODE_MAP.md exists):\n"
722
+ "```\n"
723
+ "set_project_root('/path/to/your/project')\n"
724
+ "```\n\n"
725
+ "**Option 2 - Generate CODE_MAP** (recommended):\n"
726
+ "```\n"
727
+ f"{TOOL_AGENT_ACTIVATE.format(name='discovery')}\n"
728
+ "generate_codemap('/path/to/your/project')\n"
729
+ "```\n\n"
730
+ f"Then retry: `{TOOL_AGENT_ACTIVATE.format(name=target_agent)}`\n\n"
731
+ "**Ask the user** for their project path before proceeding."
732
+ )
733
+
734
+ # Story gate: implementation agents require an active story.
735
+ if target_agent in STORY_REQUIRED_AGENTS:
736
+ active_story_id = WorkflowManager().get_active_story()
737
+ if not active_story_id:
738
+ return (
739
+ f"⛔ Cannot activate `{target_agent}`. No active story.\n\n"
740
+ "**Map to an existing story or create a new one first:**\n\n"
741
+ "List stories:\n"
742
+ "```\n"
743
+ 'workflow(action="list")\n'
744
+ "```\n\n"
745
+ "Then map (or create):\n"
746
+ "```\n"
747
+ 'workflow(action="init", story_id="STORY-XXX", epic_id="EPIC-YYY")\n'
748
+ "```\n\n"
749
+ f"Then retry: `{TOOL_AGENT_ACTIVATE.format(name=target_agent)}`"
750
+ )
751
+ # Auto-sync in-process story context from disk state.
752
+ from ref_agents.tools.workflow_tools import _sync_story_context
753
+
754
+ wf_state = WorkflowManager().get_state(active_story_id)
755
+ epic_id = wf_state.epic_id if wf_state else ""
756
+ _sync_story_context(active_story_id, epic_id)
757
+
758
+ global ACTIVE_AGENT
759
+ ACTIVE_AGENT = target_agent
760
+ SessionManager.get().active_agent = target_agent
761
+ handoff_logger.log_agent_activation(target_agent)
762
+ logger.info("agent_activated", agent=target_agent)
763
+ agent_info = AVAILABLE_AGENTS.get(target_agent, target_agent)
764
+ duty = AGENT_CAPABILITIES.get(target_agent, {}).get("duty", "")
765
+ return (
766
+ f"{Status.SUCCESS} Agent: **{agent_info}**\n\n"
767
+ f"**Duty:** {duty}\n\n"
768
+ "**EXECUTION CONTRACT** (non-negotiable):\n"
769
+ "This is role-entry, NOT task completion. Your work begins now.\n\n"
770
+ "**If sufficient context exists → begin role duties immediately.**\n"
771
+ "Sufficient context means ALL of the following are present:\n"
772
+ "1. Active story ID visible in workflow state or prior messages.\n"
773
+ '2. Current phase known (from `workflow(action="state")` output or prior messages).\n'
774
+ "3. A concrete task, artifact path, or problem statement in prior messages.\n\n"
775
+ "**If any of the above is missing → ask for it explicitly. Do NOT await silently.**\n\n"
776
+ "Sequence: activate → embody role → execute duties → "
777
+ "write all `produces` artifacts to disk → "
778
+ "verify file exists at path (if missing: do NOT call log) → "
779
+ 'log `event="phase"` complete → call `workflow(action="next")`.'
780
+ )
781
+
782
+
783
+ @mcp.tool()
784
+ def agent(
785
+ action: AgentAction,
786
+ name: str = "",
787
+ context: str = "",
788
+ ) -> str:
789
+ """Agent control. action: list | activate | identify | check."""
790
+ if action == "list":
791
+ return context_tools._format_available_agents_numbered()
792
+ if action == "activate":
793
+ if not name:
794
+ return f"{Status.ERROR} agent(action='activate') requires name=..."
795
+ return _set_active_agent_impl(name)
796
+ if action == "identify":
797
+ return context_tools.identify_agent_impl(context)
798
+ if action == "check":
799
+ if not name:
800
+ return f"{Status.ERROR} agent(action='check') requires name=..."
801
+ return guard_tools.check_agent_prerequisites_impl(name)
802
+ return f"{Status.ERROR} Unknown agent action: {action}"
803
+
804
+
805
+ # --- context.* ---
806
+ ContextAction = Literal[
807
+ "add",
808
+ "get",
809
+ "clear",
810
+ "graph_add",
811
+ "graph_related",
812
+ "graph_chain",
813
+ "graph_data",
814
+ "graph_populate",
815
+ "graph_query",
816
+ "graph_subgraph",
817
+ "graph_html",
818
+ "graph_watch",
819
+ "graph_watch_stop",
820
+ ]
821
+
822
+
823
+ @mcp.tool()
824
+ def context( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
825
+ action: ContextAction,
826
+ layer: str = "",
827
+ entry_type: str = "",
828
+ content: str = "",
829
+ identifier: str = "",
830
+ story_id: str = "",
831
+ epic_id: str = "",
832
+ node_id: str = "",
833
+ edge_types: str = "led_to,affects",
834
+ format: str = "json",
835
+ directory: str = "",
836
+ levels: str = "L1,L2",
837
+ include_git: bool = True,
838
+ include_llm: bool = True,
839
+ node_type: str = "",
840
+ links_to: str = "",
841
+ link_type: str = "led_to",
842
+ term: str = "",
843
+ depth: int = 2,
844
+ ) -> str:
845
+ """Session context. action: add|get|clear|graph_add|graph_related|graph_chain|graph_data|graph_populate|graph_query|graph_subgraph|graph_html|graph_watch|graph_watch_stop."""
846
+ _context_dispatch: dict[str, Callable[[], str]] = {
847
+ "add": lambda: context_manager.manage_context(
848
+ "add", layer, entry_type, content, identifier, story_id, epic_id
849
+ ),
850
+ "get": lambda: context_manager.manage_context(
851
+ "get", layer, entry_type, content, identifier, story_id, epic_id
852
+ ),
853
+ "clear": lambda: context_manager.manage_context(
854
+ "clear", layer, entry_type, content, identifier, story_id, epic_id
855
+ ),
856
+ "graph_add": lambda: _context_graph_add_node(
857
+ node_type, content, story_id, links_to, link_type
858
+ ),
859
+ "graph_related": lambda: _context_graph_get_related(node_id, edge_types),
860
+ "graph_chain": lambda: _context_graph_causality_chain(story_id),
861
+ "graph_data": lambda: _context_graph_data(format),
862
+ "graph_populate": lambda: _context_graph_populate(
863
+ directory, levels, include_git, include_llm
864
+ ),
865
+ "graph_query": lambda: _context_graph_query(term),
866
+ "graph_subgraph": lambda: _context_graph_subgraph(node_id, depth),
867
+ "graph_html": lambda: _context_graph_html(directory),
868
+ "graph_watch": lambda: _context_graph_watch(directory),
869
+ "graph_watch_stop": lambda: _context_graph_watch_stop(directory),
870
+ }
871
+ handler = _context_dispatch.get(action)
872
+ if handler is None:
873
+ return f"{Status.ERROR} Unknown context action: {action!r}. Valid: {list(_context_dispatch)}"
874
+ return handler()
875
+
876
+
877
+ def _context_graph_add_node( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
878
+ node_type: str,
879
+ content: str,
880
+ story_id: str,
881
+ links_to: str,
882
+ link_type: str,
883
+ ) -> str:
884
+ from datetime import datetime, timezone
885
+
886
+ from ref_agents.tools.context_graph import (
887
+ VALID_EDGE_TYPES,
888
+ VALID_NODE_TYPES,
889
+ ContextNode,
890
+ EdgeType,
891
+ NodeType,
892
+ get_context_graph,
893
+ )
894
+
895
+ if node_type not in VALID_NODE_TYPES:
896
+ return (
897
+ f"{Status.ERROR} Invalid node_type: {node_type}. Valid: {VALID_NODE_TYPES}"
898
+ )
899
+ if links_to and link_type not in VALID_EDGE_TYPES:
900
+ return (
901
+ f"{Status.ERROR} Invalid link_type: {link_type}. Valid: {VALID_EDGE_TYPES}"
902
+ )
903
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
904
+ node_id = f"{node_type}-{ts}"
905
+ graph = get_context_graph()
906
+ node = ContextNode(
907
+ id=node_id,
908
+ node_type=cast(NodeType, node_type),
909
+ content=content,
910
+ story_id=story_id,
911
+ )
912
+ graph.add_node(node)
913
+ edges_added = 0
914
+ if links_to:
915
+ for target_id in links_to.split(","):
916
+ target_id = target_id.strip()
917
+ if target_id and target_id in graph._graph:
918
+ try:
919
+ graph.add_edge(node_id, target_id, cast(EdgeType, link_type))
920
+ edges_added += 1
921
+ except ValueError:
922
+ pass
923
+ graph.save()
924
+ result = f"{Status.SUCCESS} Node added: {node_id}"
925
+ if edges_added:
926
+ result += f" (+{edges_added} edges)"
927
+ return result
928
+
929
+
930
+ def _context_graph_get_related(node_id: str, edge_types: str) -> str:
931
+ from ref_agents.tool_names import TOOL_CONTEXT_GRAPH_EMPTY_HINT
932
+ from ref_agents.tools.context_graph import get_context_graph
933
+
934
+ graph = get_context_graph()
935
+ if graph.node_count == 0:
936
+ return f"{Status.WARNING} Context graph is empty — greenfield project, no prior decisions. Run {TOOL_CONTEXT_GRAPH_EMPTY_HINT} after first story completes."
937
+ node = graph.get_node(node_id)
938
+ if not node:
939
+ return f"{Status.ERROR} Node not found: {node_id}"
940
+ types_list = [t.strip() for t in edge_types.split(",") if t.strip()]
941
+ related = graph.get_related(node_id, edge_types=types_list if types_list else None)
942
+ if not related:
943
+ return f"No related nodes found for {node_id} with edge types: {edge_types}"
944
+ lines = [f"## Related to {node_id}", ""]
945
+ for n in related:
946
+ lines.append(f"- **{n.id}** ({n.node_type}): {n.content[:80]}")
947
+ if n.story_id:
948
+ lines.append(f" - Story: {n.story_id}")
949
+ return "\n".join(lines)
950
+
951
+
952
+ def _context_graph_causality_chain(story_id: str) -> str:
953
+ from ref_agents.tools.context_graph import get_context_graph
954
+
955
+ graph = get_context_graph()
956
+ story_nodes = graph.get_nodes_by_story(story_id)
957
+ if not story_nodes:
958
+ return f"No context graph nodes found for story: {story_id}"
959
+ decisions = [n for n in story_nodes if n.node_type == "decision"]
960
+ if not decisions:
961
+ lines = [f"## Context for {story_id}", ""]
962
+ for n in story_nodes:
963
+ lines.append(f"- **{n.id}** ({n.node_type}): {n.content[:80]}")
964
+ return "\n".join(lines)
965
+ decisions.sort(key=lambda x: x.timestamp, reverse=True)
966
+ chain = graph.get_causality_chain(decisions[0].id)
967
+ lines = [f"## Decision Chain for {story_id}", ""]
968
+ for i, n in enumerate(chain):
969
+ prefix = "→ " if i > 0 else ""
970
+ lines.append(f"{prefix}**{n.id}**: {n.content[:80]}")
971
+ return "\n".join(lines)
972
+
973
+
974
+ def _context_graph_data(format: str) -> str:
975
+ from ref_agents.tool_names import TOOL_CONTEXT_GRAPH_EMPTY_HINT
976
+ from ref_agents.tools.context_graph import get_context_graph
977
+
978
+ graph = get_context_graph()
979
+ if graph.node_count == 0:
980
+ return f"{Status.ERROR} Context graph is empty. Run {TOOL_CONTEXT_GRAPH_EMPTY_HINT} first."
981
+ if format == "viz":
982
+ return graph.to_visualization_json()
983
+ return graph.to_json()
984
+
985
+
986
+ def _context_graph_query(term: str) -> str:
987
+ from ref_agents.tools.context_graph import get_context_graph
988
+
989
+ if not term:
990
+ return f"{Status.ERROR} 'term' required for graph_query"
991
+ graph = get_context_graph()
992
+ matches = graph.query_nodes(term)
993
+ if not matches:
994
+ return f"No nodes matching '{term}'"
995
+ total = len(matches)
996
+ shown = matches[:20]
997
+ header = f"## Graph query: '{term}' ({total} results"
998
+ header += ", showing first 20)" if total > 20 else ")" # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
999
+ lines = [header + "\n"]
1000
+ for n in shown:
1001
+ lines.append(f"- **{n.id}** ({n.node_type}): {n.content[:100]}")
1002
+ return "\n".join(lines)
1003
+
1004
+
1005
+ def _context_graph_subgraph(node_id: str, depth: int) -> str:
1006
+ from ref_agents.tools.context_graph import get_context_graph
1007
+
1008
+ if not node_id:
1009
+ return f"{Status.ERROR} 'node_id' required for graph_subgraph"
1010
+ graph = get_context_graph()
1011
+ nodes = graph.subgraph(node_id, depth)
1012
+ if not nodes:
1013
+ return f"{Status.ERROR} Node not found: {node_id}"
1014
+
1015
+ lines = [f"## Subgraph of '{node_id}' (depth={depth}, {len(nodes)} nodes)\n"]
1016
+ for n in nodes:
1017
+ lines.append(f"- **{n.id}** ({n.node_type}): {n.content[:100]}")
1018
+ return "\n".join(lines)
1019
+
1020
+
1021
+ def _context_graph_html(directory: str) -> str:
1022
+ from ref_agents.tools.context_graph import get_context_graph
1023
+
1024
+ root = Path(directory).resolve() if directory else None
1025
+ if root is None:
1026
+ root = SessionManager.get().project_root or Path.cwd()
1027
+
1028
+ # Use singleton — avoids dropping in-memory state that was never saved (RC8)
1029
+ graph = get_context_graph()
1030
+
1031
+ # Auto-populate if empty — no need for a separate graph_populate call
1032
+ if graph.node_count == 0:
1033
+ try:
1034
+ from ref_agents.tools.brownfield_populator import BrownfieldPopulator
1035
+
1036
+ populator = BrownfieldPopulator(root)
1037
+ populator.populate(
1038
+ levels=["L1", "L2"], include_git=False, include_llm=False
1039
+ )
1040
+ graph = get_context_graph()
1041
+ except (OSError, ValueError, RuntimeError) as e:
1042
+ logger.warning(
1043
+ "graph_html_auto_populate_failed", root=str(root), error=str(e)
1044
+ )
1045
+
1046
+ if graph.node_count == 0:
1047
+ return f"{Status.ERROR} No graph data found. Run context(action='graph_populate', directory='{root}') first."
1048
+
1049
+ html = graph.to_html()
1050
+ out_dir = root / "ref-reports"
1051
+ out_dir.mkdir(parents=True, exist_ok=True)
1052
+ out_path = out_dir / "graph.html"
1053
+ out_path.write_text(html, encoding="utf-8")
1054
+ return f"{Status.SUCCESS} Graph viewer saved: `{out_path}` ({graph.node_count} nodes, {graph.edge_count} edges)"
1055
+
1056
+
1057
+ # Module-level observer registry — keyed by resolved root path string
1058
+ _ast_observers: dict[str, Any] = {}
1059
+
1060
+
1061
+ def _context_graph_watch(directory: str) -> str:
1062
+ """Start watchdog file watcher for incremental AST re-extraction on file save."""
1063
+ import threading
1064
+
1065
+ from watchdog.events import ( # type: ignore[import-untyped]
1066
+ FileSystemEvent,
1067
+ FileSystemEventHandler,
1068
+ )
1069
+ from watchdog.observers import Observer # type: ignore[import-untyped]
1070
+
1071
+ from ref_agents.tools.brownfield_populator import BrownfieldPopulator
1072
+
1073
+ root = Path(directory).resolve() if directory else Path.cwd()
1074
+ root_key = str(root)
1075
+
1076
+ if root_key in _ast_observers:
1077
+ return f"{Status.SUCCESS} AST watcher already running for `{root}`"
1078
+
1079
+ # Instantiate once — reused across all on_modified calls to avoid repeated
1080
+ # singleton churn and graph saves on every event.
1081
+ populator = BrownfieldPopulator(root)
1082
+
1083
+ class _ASTRebuildHandler(FileSystemEventHandler):
1084
+ _WATCHED_EXTS = {".py", ".ts", ".tsx", ".md"}
1085
+
1086
+ def on_modified(self, event: FileSystemEvent) -> None:
1087
+ if event.is_directory:
1088
+ return
1089
+ p = Path(str(event.src_path))
1090
+ if p.suffix not in self._WATCHED_EXTS:
1091
+ return
1092
+ try:
1093
+ rel = str(p.relative_to(root))
1094
+ populator._scan_structural_single(p, rel)
1095
+ populator.graph.save()
1096
+ logger.info("ast_graph_incremental_update", file=rel)
1097
+ except (OSError, SyntaxError, ValueError) as e:
1098
+ logger.warning("ast_watch_rebuild_failed", error=str(e))
1099
+
1100
+ observer = Observer()
1101
+ observer.schedule(_ASTRebuildHandler(), root_key, recursive=True)
1102
+ _ast_observers[root_key] = observer
1103
+ t = threading.Thread(target=observer.start, daemon=True, name="ref-ast-watcher")
1104
+ t.start()
1105
+ return f"{Status.SUCCESS} AST file watcher started for `{root}` (daemon thread)"
1106
+
1107
+
1108
+ def _context_graph_watch_stop(directory: str = "") -> str:
1109
+ """Stop a running AST file watcher. Pass 'all' to stop every active watcher."""
1110
+ if directory == "all":
1111
+ stopped: list[str] = []
1112
+ for key, obs in list(_ast_observers.items()):
1113
+ try:
1114
+ obs.stop()
1115
+ obs.join(timeout=5)
1116
+ stopped.append(key)
1117
+ except (RuntimeError, OSError):
1118
+ pass
1119
+ _ast_observers.clear()
1120
+ return f"{Status.SUCCESS} Stopped {len(stopped)} watcher(s)"
1121
+
1122
+ root_key = str(Path(directory).resolve()) if directory else str(Path.cwd())
1123
+ observer = _ast_observers.pop(root_key, None)
1124
+ if observer is None:
1125
+ return f"{Status.ERROR} No watcher running for `{root_key}`"
1126
+ try:
1127
+ observer.stop()
1128
+ observer.join(timeout=5)
1129
+ except (RuntimeError, OSError) as e:
1130
+ return f"{Status.ERROR} Watcher stop failed: {e}"
1131
+ return f"{Status.SUCCESS} AST file watcher stopped for `{root_key}`"
1132
+
1133
+
1134
+ def _context_graph_populate(
1135
+ directory: str, levels: str, include_git: bool, include_llm: bool
1136
+ ) -> str:
1137
+ from ref_agents.tools.brownfield_populator import (
1138
+ BrownfieldPopulator,
1139
+ CriticalityLevel,
1140
+ )
1141
+
1142
+ try:
1143
+ path = Path(directory) if directory else None
1144
+ populator = BrownfieldPopulator(path)
1145
+ level_list: list[CriticalityLevel] = [
1146
+ cast(CriticalityLevel, level.strip())
1147
+ for level in levels.split(",")
1148
+ if level.strip()
1149
+ ]
1150
+ stats = populator.populate(
1151
+ levels=level_list, include_git=include_git, include_llm=include_llm
1152
+ )
1153
+ return _format_population_stats(populator.root, stats, list(level_list))
1154
+ except (OSError, ValueError, RuntimeError) as e:
1155
+ logger.error("brownfield_population_failed", error=str(e))
1156
+ raise
1157
+
1158
+
1159
+ # --- story.* ---
1160
+ StoryAction = Literal["dependencies", "ready", "critical_path", "analysis"]
1161
+
1162
+
1163
+ @mcp.tool()
1164
+ def story(action: StoryAction, directory: str = "") -> str:
1165
+ """Story sequencing. action: dependencies | ready | critical_path | analysis."""
1166
+ if action == "dependencies":
1167
+ return dependency_graph.generate_dependency_graph(directory or ".")
1168
+ if action == "ready":
1169
+ return sequencing_engine.get_next_ready_stories(directory or ".")
1170
+ if action == "critical_path":
1171
+ return sequencing_engine.get_critical_path_report(directory or ".")
1172
+ if action == "analysis":
1173
+ result = sequencing_engine.analyze_directory(directory or ".")
1174
+ if result.errors:
1175
+ return f"Errors: {result.errors}"
1176
+ return result.to_json()
1177
+ return f"{Status.ERROR} Unknown story action: {action}"
1178
+
1179
+
1180
+ # --- browser.* ---
1181
+ BrowserAction = Literal["check", "write_tests", "execute", "verify"]
1182
+
1183
+
1184
+ @mcp.tool()
1185
+ def browser(
1186
+ action: BrowserAction,
1187
+ story_id: str = "",
1188
+ test_cases_json: str = "",
1189
+ ) -> str:
1190
+ """Browser/E2E testing. action: check | write_tests | execute | verify."""
1191
+ if action == "check":
1192
+ from ref_agents.tools.browser.playwright_mcp_client import (
1193
+ check_browser_tools_available,
1194
+ )
1195
+
1196
+ if check_browser_tools_available():
1197
+ return "✅ Playwright MCP browser tools are available.\nReady for frontend E2E testing."
1198
+ return (
1199
+ "❌ Playwright MCP browser tools are NOT available.\n\n"
1200
+ "To enable: Ensure Playwright MCP is enabled in Cursor settings, check MCP config, restart Cursor."
1201
+ )
1202
+ if action == "write_tests":
1203
+ return workflow_tools.write_conceptual_tests_tool(story_id, test_cases_json)
1204
+ if action == "execute":
1205
+ from ref_agents.tools.browser.test_executor import (
1206
+ execute_frontend_tests as exec_tests,
1207
+ )
1208
+
1209
+ return exec_tests(story_id)
1210
+ if action == "verify":
1211
+ from ref_agents.tools.browser.evidence_verifier import (
1212
+ verify_test_evidence as verify,
1213
+ )
1214
+
1215
+ result = verify(story_id)
1216
+ if result.valid:
1217
+ return f"✅ Evidence verified for {story_id}\nScreenshots: {result.screenshot_count}\nLog entries: {result.log_entry_count}"
1218
+ return (
1219
+ f"❌ Evidence verification FAILED for {story_id}\nIssues: {result.issues}"
1220
+ )
1221
+ return f"{Status.ERROR} Unknown browser action: {action}"
1222
+
1223
+
1224
+ # --- Standalone workflow/context ---
1225
+ @mcp.tool()
1226
+ def check_testing_capabilities(story_id: str = "") -> str:
1227
+ """Detects available testing tools (Playwright MCP, pytest) for story type."""
1228
+ return workflow_tools.check_testing_capabilities(story_id)
1229
+
1230
+
1231
+ @mcp.tool()
1232
+ def defer_e2e_testing(story_id: str = "", reason: str = "") -> str:
1233
+ """Defers E2E testing and logs to TECH_DEBT.md. Requires user confirmation."""
1234
+ return workflow_tools.defer_e2e_testing(story_id, reason)
1235
+
1236
+
1237
+ @mcp.tool()
1238
+ def set_story_context(story_id: str, epic_id: str = "") -> str:
1239
+ """Sets active story context for agent prompts and workflow tracking."""
1240
+ start_agent.set_story_context(story_id, epic_id)
1241
+ return f"{Status.SUCCESS} Context set: story={story_id}"
1242
+
1243
+
1244
+ @mcp.tool()
1245
+ def manage_context( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
1246
+ action: str,
1247
+ layer: str = "",
1248
+ entry_type: str = "",
1249
+ content: str = "",
1250
+ identifier: str = "",
1251
+ story_id: str = "",
1252
+ epic_id: str = "",
1253
+ ) -> str:
1254
+ """Manages context. action: add | get | clear."""
1255
+ return context_manager.manage_context(
1256
+ action, layer, entry_type, content, identifier, story_id, epic_id
1257
+ )
1258
+
1259
+
1260
+ @mcp.tool()
1261
+ def add_blocker(story_id: str, level: str, description: str, source_agent: str) -> str:
1262
+ """Adds blocker to story. level: L1 | L2 | L3. Triggers escalation log."""
1263
+ manager = WorkflowManager()
1264
+ try:
1265
+ state = manager.add_blocker(story_id, level, description, source_agent)
1266
+ handoff_logger.log_escalation(level, source_agent, [], description, story_id)
1267
+ return (
1268
+ f"🛑 Blocker added to {story_id}\n"
1269
+ f"Level: {level}\n"
1270
+ f"Description: {description}\n"
1271
+ f"Source: {source_agent}\n"
1272
+ f"Total blockers: {len(state.blockers)}"
1273
+ )
1274
+ except ValueError as e:
1275
+ return f"{Status.ERROR} Error: {e}"
1276
+
1277
+
1278
+ @mcp.tool()
1279
+ def resolve_blocker(story_id: str, index: int = -1) -> str:
1280
+ """Resolves blocker by index (-1 for most recent). Updates workflow state."""
1281
+ return workflow_tools.resolve_blocker_tool(story_id, index)
1282
+
1283
+
1284
+ @mcp.tool()
1285
+ def notify_background_agent(story_id: str, notification: str) -> str:
1286
+ """Queues notification for primary agent from background agent."""
1287
+ return workflow_tools.add_background_notification(story_id, notification)
1288
+
1289
+
1290
+ @mcp.tool()
1291
+ def request_debt_fix_approval(
1292
+ debt_id: str, description: str, story_id: str = ""
1293
+ ) -> str:
1294
+ """Requests approval to fix tech debt. Logs L3 escalation. Halts for decision."""
1295
+ return workflow_tools.request_debt_fix_approval_tool(debt_id, description, story_id)
1296
+
1297
+
1298
+ # --- Flow ---
1299
+ @mcp.tool()
1300
+ def generate_flow(directory: str, output: str = "diagram") -> str:
1301
+ """Generates flow from requirements. output: diagram | summary."""
1302
+ return flow_mapper.generate_flow(directory, output)
1303
+
1304
+
1305
+ @mcp.tool()
1306
+ def detect_flow_gaps(flows_file: str, req_directory: str, summary: bool = False) -> str:
1307
+ """Detects flow gaps. Set summary=True for quick overview."""
1308
+ return flow_gap_detector.detect_flow_gaps(flows_file, req_directory, summary)
1309
+
1310
+
1311
+ # --- External ---
1312
+ @mcp.tool()
1313
+ def validate_external_dependencies(registry_file: str, code_directory: str) -> str:
1314
+ """Validates code uses only registered external APIs from EXTERNAL_APIS.md."""
1315
+ return cross_repo_linker.validate_external_deps(registry_file, code_directory)
1316
+
1317
+
1318
+ @mcp.tool()
1319
+ def list_unregistered_dependencies(code_directory: str) -> str:
1320
+ """Lists external API calls not registered in EXTERNAL_APIS.md."""
1321
+ return cross_repo_linker.get_unregistered_deps(code_directory)
1322
+
1323
+
1324
+ # --- Pattern ---
1325
+ @mcp.tool()
1326
+ def pattern( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
1327
+ action: str,
1328
+ pattern_id: str = "",
1329
+ name: str = "",
1330
+ domain: str = "",
1331
+ tech: str = "",
1332
+ story_id: str = "",
1333
+ story_title: str = "",
1334
+ files: str = "",
1335
+ snippet: str = "",
1336
+ tags: str = "",
1337
+ description: str = "",
1338
+ ) -> str:
1339
+ """Pattern management. action: capture | suggest | get | list."""
1340
+ return pattern_learner.pattern_tool(
1341
+ action,
1342
+ pattern_id,
1343
+ name,
1344
+ domain,
1345
+ tech,
1346
+ story_id,
1347
+ story_title,
1348
+ files,
1349
+ snippet,
1350
+ tags,
1351
+ description,
1352
+ )
1353
+
1354
+
1355
+ # --- Other ---
1356
+ @mcp.tool()
1357
+ def generate_project_summary(directory: str = "") -> str:
1358
+ """Generates executive-summary.json with score, debt, security, compliance."""
1359
+ return summary_generator.generate_project_summary_tool(directory)
1360
+
1361
+
1362
+ # --- Discovery Artifact Tools ---
1363
+ @mcp.tool()
1364
+ def generate_features(directory: str) -> str:
1365
+ """Infer FEATURES.md from codemap/ directory.
1366
+
1367
+ Analyzes codemap package maps to identify user-facing features
1368
+ and generates a FEATURES.md file at project root.
1369
+
1370
+ Args:
1371
+ directory: Path to project root (must have codemap/ first).
1372
+
1373
+ Returns:
1374
+ Status with feature count and categories.
1375
+ """
1376
+ from ref_agents.tools.features_generator import generate_features_file
1377
+
1378
+ return generate_features_file(directory)
1379
+
1380
+
1381
+ @mcp.tool()
1382
+ def generate_agents_md(directory: str) -> str:
1383
+ """Generate AGENTS.md with REF protocol and fix workflow.
1384
+
1385
+ Creates AGENTS.md at project root containing:
1386
+ - REF-Agents MCP entry point requirement
1387
+ - Communication style protocol
1388
+ - Mandatory fix workflow (forensic_engineer -> strategist -> developer)
1389
+
1390
+ Args:
1391
+ directory: Path to project root.
1392
+
1393
+ Returns:
1394
+ Status message.
1395
+ """
1396
+ from ref_agents.tools.agents_generator import generate_agents_file
1397
+
1398
+ return generate_agents_file(directory)
1399
+
1400
+
1401
+ # --- hierarchy.* ---
1402
+ @mcp.tool()
1403
+ def create_story( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
1404
+ story_id: str,
1405
+ title: str,
1406
+ user_story: str,
1407
+ epic_id: str = "",
1408
+ feature_id: str = "",
1409
+ description: str = "",
1410
+ requirements_text: str = "",
1411
+ ) -> str:
1412
+ """Create story doc at docs/requirements/STORY-XXX.md + JSON. Required before workflow init.
1413
+
1414
+ Pass requirements_text to persist inline terminal/chat requirements verbatim under
1415
+ a '## Raw Requirements' section — ensures no input is lost if the user typed
1416
+ requirements into the chat rather than supplying a document.
1417
+ """
1418
+ try:
1419
+ story = hierarchy_manager.create_story(
1420
+ story_id=story_id,
1421
+ title=title,
1422
+ user_story=user_story,
1423
+ epic_id=epic_id or None,
1424
+ feature_id=feature_id or None,
1425
+ description=description,
1426
+ requirements_text=requirements_text,
1427
+ )
1428
+ path = f"docs/requirements/{story_id}.md"
1429
+ return (
1430
+ f"{Status.SUCCESS} Story created: {path}\nEpic: {story.epic_id or 'None'}"
1431
+ )
1432
+ except OSError as e:
1433
+ return f"{Status.ERROR} Failed to create story: {e}"
1434
+
1435
+
1436
+ @mcp.tool()
1437
+ def create_epic(
1438
+ epic_id: str,
1439
+ title: str,
1440
+ description: str = "",
1441
+ ) -> str:
1442
+ """Create epic doc at docs/requirements/EPIC-XXX.md + JSON."""
1443
+ try:
1444
+ hierarchy_manager.create_epic(
1445
+ epic_id=epic_id, title=title, description=description
1446
+ )
1447
+ path = f"docs/requirements/{epic_id}.md"
1448
+ return f"{Status.SUCCESS} Epic created: {path}"
1449
+ except OSError as e:
1450
+ return f"{Status.ERROR} Failed to create epic: {e}"
1451
+
1452
+
1453
+ @mcp.tool()
1454
+ def create_feature(
1455
+ feature_id: str,
1456
+ epic_id: str,
1457
+ title: str,
1458
+ description: str = "",
1459
+ ) -> str:
1460
+ """Create feature doc at docs/requirements/FEAT-XXX.md + JSON."""
1461
+ try:
1462
+ hierarchy_manager.create_feature(
1463
+ feature_id=feature_id,
1464
+ epic_id=epic_id,
1465
+ title=title,
1466
+ description=description,
1467
+ )
1468
+ path = f"docs/requirements/{feature_id}.md"
1469
+ return f"{Status.SUCCESS} Feature created: {path}"
1470
+ except OSError as e:
1471
+ return f"{Status.ERROR} Failed to create feature: {e}"
1472
+
1473
+
1474
+ @mcp.tool()
1475
+ def audit_discovery(directory: str) -> str:
1476
+ """Audit discovery artifacts for completeness.
1477
+
1478
+ Post-completion verification checking:
1479
+ - Artifact existence (codemap/, FEATURES.md, AGENTS.md, etc.)
1480
+ - Placement validation (no misplaced files, no unknown/)
1481
+ - Content validation (required sections present)
1482
+
1483
+ Args:
1484
+ directory: Path to project root.
1485
+
1486
+ Returns:
1487
+ Audit result with pass/fail checks and remediation steps.
1488
+ """
1489
+ from ref_agents.tools.discovery_audit import audit_discovery as run_audit
1490
+
1491
+ return run_audit(directory)
1492
+
1493
+
1494
+ def _format_population_stats(root: Path, stats: dict, levels: list[str]) -> str:
1495
+ """Format population statistics."""
1496
+ from ref_agents.constants import Status
1497
+
1498
+ lines = [
1499
+ f"{Status.SUCCESS} Brownfield Population Complete",
1500
+ "",
1501
+ f"Root: {root}",
1502
+ f"Levels: {', '.join(levels)}",
1503
+ "",
1504
+ "Stats:",
1505
+ f"- Files Scanned: {stats.get('files_scanned', 0)}",
1506
+ f"- Deep Analysis: {stats.get('deep_analysis_files', 0)}",
1507
+ f"- Total Nodes: {stats.get('total_nodes', 0)}",
1508
+ f"- Total Edges: {stats.get('total_edges', 0)}",
1509
+ ]
1510
+ return "\n".join(lines)
1511
+
1512
+
1513
+ RemediationTierLiteral = Literal["tier1", "tier2"]
1514
+
1515
+
1516
+ @mcp.tool()
1517
+ def remediate( # noqa: PLR0913 # MCP tool signature — FastMCP interface constraint
1518
+ action: Literal[
1519
+ "start", "state", "execute", "approve", "defer", "update", "complete"
1520
+ ],
1521
+ session_id: str = "",
1522
+ story_id: str = "",
1523
+ directory: str = "",
1524
+ scan_output: str = "",
1525
+ tier: RemediationTierLiteral = "tier1",
1526
+ violation_ids: list[str] | None = None,
1527
+ violation_id: str = "",
1528
+ status: str = "",
1529
+ reason: str = "",
1530
+ notes: str = "",
1531
+ ) -> str:
1532
+ """Autonomous compliance remediation. action: start | state | execute | approve | defer | update | complete.
1533
+
1534
+ start — Parse scan output, classify violations into tiers, return plan.
1535
+ state — Show current session progress and pending decisions.
1536
+ execute — Return ordered work plan for tier1 (autonomous) or tier2 (approved).
1537
+ approve — Approve Tier 2 batch for execution (single gate for all Tier 2 fixes).
1538
+ defer — Defer a single violation to TECH_DEBT.md instead of fixing.
1539
+ update — Mark violations as auto_fixed | failed after agent completes fixes.
1540
+ complete — Close session after PR Reviewer gate passes.
1541
+ """
1542
+ from ref_agents.tools.compliance_remediation import (
1543
+ approve_tier2_batch,
1544
+ complete_remediation,
1545
+ defer_violation,
1546
+ execute_remediation,
1547
+ get_remediation_state,
1548
+ start_remediation,
1549
+ update_violation_status,
1550
+ )
1551
+
1552
+ _remediation_dispatch: dict[str, Callable[[], str]] = {
1553
+ "start": lambda: start_remediation(
1554
+ story_id=story_id, directory=directory, scan_output=scan_output
1555
+ ),
1556
+ "state": lambda: get_remediation_state(session_id=session_id),
1557
+ "execute": lambda: execute_remediation(session_id=session_id, tier=tier),
1558
+ "approve": lambda: approve_tier2_batch(session_id=session_id),
1559
+ "defer": lambda: defer_violation(
1560
+ session_id=session_id, violation_id=violation_id, reason=reason
1561
+ ),
1562
+ "update": lambda: update_violation_status(
1563
+ session_id=session_id,
1564
+ violation_ids=violation_ids or [],
1565
+ status=status,
1566
+ notes=notes,
1567
+ ),
1568
+ "complete": lambda: complete_remediation(session_id=session_id),
1569
+ }
1570
+ handler = _remediation_dispatch.get(action)
1571
+ if handler is None:
1572
+ return f"{Status.ERROR} Unknown remediation action: {action!r}. Valid: {list(_remediation_dispatch)}"
1573
+ return handler()
1574
+
1575
+
1576
+ def main() -> None:
1577
+ """Entry point for CLI execution via uvx/pip.
1578
+
1579
+ Uses stdio transport (works with Cursor, Claude Desktop, Windsurf).
1580
+ Run via: `uvx ref-agents` (after publishing to PyPI)
1581
+ """
1582
+ mcp.run(show_banner=False)
1583
+
1584
+
1585
+ if __name__ == "__main__":
1586
+ main()