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
@@ -0,0 +1,839 @@
1
+ """Context Graph for REF-Agents.
2
+
3
+ NetworkX-based graph for tracking relationships between:
4
+ - Decisions
5
+ - Patterns
6
+ - Files
7
+ - Issues
8
+ - Agent sessions
9
+ - Stories
10
+
11
+ Provides causality chains and impact analysis for agent context.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any, Literal
21
+
22
+ import networkx as nx
23
+
24
+ from ref_agents.utils.git_utils import find_project_root
25
+
26
+ # Valid node and edge types
27
+ NodeType = Literal[
28
+ "decision",
29
+ "pattern",
30
+ "file",
31
+ "issue",
32
+ "agent_session",
33
+ "story",
34
+ "module",
35
+ "class",
36
+ "function",
37
+ ]
38
+ EdgeType = Literal[
39
+ "led_to",
40
+ "affects",
41
+ "discovered_by",
42
+ "applies_to",
43
+ "supersedes",
44
+ "depends_on",
45
+ "imports",
46
+ "calls",
47
+ "inherits",
48
+ "contains",
49
+ ]
50
+
51
+ # AST-origin node types — stored in .ref_cache/ast_graph.json, regenerable
52
+ AST_NODE_TYPES: set[str] = {"module", "class", "function", "file"}
53
+
54
+ VALID_NODE_TYPES: set[str] = {
55
+ "decision",
56
+ "pattern",
57
+ "file",
58
+ "issue",
59
+ "agent_session",
60
+ "story",
61
+ "module",
62
+ "class",
63
+ "function",
64
+ }
65
+ VALID_EDGE_TYPES: set[str] = {
66
+ "led_to",
67
+ "affects",
68
+ "discovered_by",
69
+ "applies_to",
70
+ "supersedes",
71
+ "depends_on",
72
+ "imports",
73
+ "calls",
74
+ "inherits",
75
+ "contains",
76
+ }
77
+
78
+ GRAPH_FILENAME = "context_graph.json"
79
+
80
+
81
+ @dataclass
82
+ class ContextNode:
83
+ """Node in the context graph."""
84
+
85
+ id: str
86
+ node_type: NodeType
87
+ content: str
88
+ timestamp: str = field(
89
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
90
+ )
91
+ story_id: str = ""
92
+ # "ast" = extracted by tree-sitter (regenerable); "context" = human/agent-authored
93
+ origin: str = "context"
94
+ metadata: dict[str, Any] = field(default_factory=dict)
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ """Convert to dictionary for serialization."""
98
+ return {
99
+ "id": self.id,
100
+ "node_type": self.node_type,
101
+ "content": self.content,
102
+ "timestamp": self.timestamp,
103
+ "story_id": self.story_id,
104
+ "origin": self.origin,
105
+ "metadata": self.metadata,
106
+ }
107
+
108
+ @classmethod
109
+ def from_dict(cls, data: dict[str, Any]) -> ContextNode:
110
+ """Create from dictionary."""
111
+ return cls(
112
+ id=data["id"],
113
+ node_type=data["node_type"],
114
+ content=data["content"],
115
+ timestamp=data.get("timestamp", ""),
116
+ story_id=data.get("story_id", ""),
117
+ origin=data.get("origin", "context"),
118
+ metadata=data.get("metadata", {}),
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class ContextEdge:
124
+ """Edge in the context graph."""
125
+
126
+ from_id: str
127
+ to_id: str
128
+ edge_type: EdgeType
129
+ timestamp: str = field(
130
+ default_factory=lambda: datetime.now(timezone.utc).isoformat()
131
+ )
132
+ # 1.0 = certain (AST-extracted or human), 0.5–0.9 = inferred (scanner/git/LLM)
133
+ confidence: float = 1.0
134
+ metadata: dict[str, Any] = field(default_factory=dict)
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ """Convert to dictionary for serialization."""
138
+ return {
139
+ "from_id": self.from_id,
140
+ "to_id": self.to_id,
141
+ "edge_type": self.edge_type,
142
+ "timestamp": self.timestamp,
143
+ "confidence": self.confidence,
144
+ "metadata": self.metadata,
145
+ }
146
+
147
+ @classmethod
148
+ def from_dict(cls, data: dict[str, Any]) -> ContextEdge:
149
+ """Create from dictionary."""
150
+ return cls(
151
+ from_id=data["from_id"],
152
+ to_id=data["to_id"],
153
+ edge_type=data["edge_type"],
154
+ timestamp=data.get("timestamp", ""),
155
+ confidence=float(data.get("confidence", 1.0)),
156
+ metadata=data.get("metadata", {}),
157
+ )
158
+
159
+
160
+ class ContextGraph:
161
+ """Directed graph representing the codebase context and decision history."""
162
+
163
+ def __init__(self, root: Path | None = None):
164
+ self._root = root or find_project_root() or Path.cwd()
165
+
166
+ # Primary storage (Dashboards, Recovery)
167
+ self._primary_dir = self._root / "ref-reports"
168
+ self._primary_file = self._primary_dir / GRAPH_FILENAME
169
+
170
+ # Cache storage (Runtime performance)
171
+ self._cache_dir = self._root / ".ref_cache"
172
+ self._cache_file = self._cache_dir / GRAPH_FILENAME
173
+
174
+ self._graph = nx.DiGraph()
175
+
176
+ @property
177
+ def node_count(self) -> int:
178
+ """Number of nodes in graph."""
179
+ return self._graph.number_of_nodes()
180
+
181
+ @property
182
+ def edge_count(self) -> int:
183
+ """Number of edges in graph."""
184
+ return self._graph.number_of_edges()
185
+
186
+ def add_node(self, node: ContextNode) -> None:
187
+ """Add a node to the graph.
188
+
189
+ Args:
190
+ node: ContextNode to add.
191
+
192
+ Raises:
193
+ ValueError: If node_type is invalid.
194
+ """
195
+ if node.node_type not in VALID_NODE_TYPES:
196
+ raise ValueError(
197
+ f"Invalid node_type: {node.node_type}. Must be one of {VALID_NODE_TYPES}"
198
+ )
199
+
200
+ self._graph.add_node(node.id, **node.to_dict())
201
+
202
+ def add_edge( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
203
+ self,
204
+ from_id: str,
205
+ to_id: str,
206
+ edge_type: EdgeType,
207
+ metadata: dict[str, Any] | None = None,
208
+ confidence: float = 1.0,
209
+ ) -> None:
210
+ """Add an edge between two nodes.
211
+
212
+ Args:
213
+ from_id: Source node ID.
214
+ to_id: Target node ID.
215
+ edge_type: Type of relationship.
216
+ metadata: Optional edge metadata.
217
+ confidence: Certainty of relationship (1.0=certain, 0.5=inferred).
218
+
219
+ Raises:
220
+ ValueError: If edge_type is invalid or nodes don't exist.
221
+ """
222
+ if edge_type not in VALID_EDGE_TYPES:
223
+ raise ValueError(
224
+ f"Invalid edge_type: {edge_type}. Must be one of {VALID_EDGE_TYPES}"
225
+ )
226
+
227
+ if from_id not in self._graph:
228
+ raise ValueError(f"Source node not found: {from_id}")
229
+ if to_id not in self._graph:
230
+ raise ValueError(f"Target node not found: {to_id}")
231
+
232
+ edge = ContextEdge(
233
+ from_id=from_id,
234
+ to_id=to_id,
235
+ edge_type=edge_type,
236
+ confidence=confidence,
237
+ metadata=metadata or {},
238
+ )
239
+ self._graph.add_edge(from_id, to_id, **edge.to_dict())
240
+
241
+ def get_node(self, node_id: str) -> ContextNode | None:
242
+ """Get a node by ID.
243
+
244
+ Args:
245
+ node_id: Node identifier.
246
+
247
+ Returns:
248
+ ContextNode if found, None otherwise.
249
+ """
250
+ if node_id not in self._graph:
251
+ return None
252
+
253
+ data = dict(self._graph.nodes[node_id])
254
+ return ContextNode.from_dict(data)
255
+
256
+ def get_related(
257
+ self,
258
+ node_id: str,
259
+ edge_types: list[str] | None = None,
260
+ direction: Literal["outgoing", "incoming", "both"] = "both",
261
+ ) -> list[ContextNode]:
262
+ """Get nodes related to a given node.
263
+
264
+ Args:
265
+ node_id: Source node ID.
266
+ edge_types: Filter by edge types. None = all types.
267
+ direction: Which edges to follow.
268
+
269
+ Returns:
270
+ List of related ContextNodes.
271
+ """
272
+ if node_id not in self._graph:
273
+ return []
274
+
275
+ related_ids: set[str] = set()
276
+
277
+ if direction in ("outgoing", "both"):
278
+ for _, target, data in self._graph.out_edges(node_id, data=True):
279
+ if edge_types is None or data.get("edge_type") in edge_types:
280
+ related_ids.add(target)
281
+
282
+ if direction in ("incoming", "both"):
283
+ for source, _, data in self._graph.in_edges(node_id, data=True):
284
+ if edge_types is None or data.get("edge_type") in edge_types:
285
+ related_ids.add(source)
286
+
287
+ return [n for nid in related_ids if (n := self.get_node(nid)) is not None]
288
+
289
+ def get_causality_chain(
290
+ self, node_id: str, max_depth: int = 10
291
+ ) -> list[ContextNode]:
292
+ """Get the causal ancestry of a node (led_to, caused_by edges).
293
+
294
+ Args:
295
+ node_id: Starting node ID.
296
+ max_depth: Maximum traversal depth.
297
+
298
+ Returns:
299
+ List of nodes in causal chain, oldest first.
300
+ """
301
+ if node_id not in self._graph:
302
+ return []
303
+
304
+ chain: list[str] = []
305
+ visited: set[str] = set()
306
+ current = node_id
307
+
308
+ for _ in range(max_depth):
309
+ visited.add(current)
310
+ chain.append(current)
311
+
312
+ # Find predecessor via led_to edge
313
+ predecessors = [
314
+ source
315
+ for source, _, data in self._graph.in_edges(current, data=True)
316
+ if data.get("edge_type") == "led_to" and source not in visited
317
+ ]
318
+
319
+ if not predecessors:
320
+ break
321
+ current = predecessors[0]
322
+
323
+ # Reverse to get oldest first
324
+ chain.reverse()
325
+ return [n for nid in chain if (n := self.get_node(nid)) is not None]
326
+
327
+ def get_nodes_by_story(self, story_id: str) -> list[ContextNode]:
328
+ """Get all nodes associated with a story.
329
+
330
+ Args:
331
+ story_id: Story identifier.
332
+
333
+ Returns:
334
+ List of ContextNodes for the story.
335
+ """
336
+ nodes = []
337
+ for node_id in self._graph.nodes:
338
+ data = self._graph.nodes[node_id]
339
+ if data.get("story_id") == story_id:
340
+ nodes.append(ContextNode.from_dict(data))
341
+ return nodes
342
+
343
+ def get_nodes_by_type(self, node_type: NodeType) -> list[ContextNode]:
344
+ """Get all nodes of a specific type.
345
+
346
+ Args:
347
+ node_type: Type of node to filter by.
348
+
349
+ Returns:
350
+ List of ContextNodes of the given type.
351
+ """
352
+ nodes = []
353
+ for node_id in self._graph.nodes:
354
+ data = self._graph.nodes[node_id]
355
+ if data.get("node_type") == node_type:
356
+ nodes.append(ContextNode.from_dict(data))
357
+ return nodes
358
+
359
+ def query_nodes(self, term: str) -> list[ContextNode]:
360
+ """Search nodes whose content or ID contains term (case-insensitive).
361
+
362
+ Args:
363
+ term: Search string. Empty string returns [].
364
+
365
+ Returns:
366
+ List of matching ContextNodes.
367
+ """
368
+ if not term:
369
+ return []
370
+ term_lower = term.lower()
371
+ results: list[ContextNode] = []
372
+ for node_id in self._graph.nodes:
373
+ data = self._graph.nodes[node_id]
374
+ if (
375
+ term_lower in data.get("content", "").lower()
376
+ or term_lower in node_id.lower()
377
+ ):
378
+ results.append(ContextNode.from_dict(data))
379
+ return results
380
+
381
+ def subgraph(self, node_id: str, depth: int = 2) -> list[ContextNode]:
382
+ """Return all nodes within BFS depth of node_id (both directions).
383
+
384
+ Args:
385
+ node_id: Starting node ID.
386
+ depth: BFS traversal depth. Values <= 0 are treated as 1 (returns
387
+ direct neighbours). Use get_node() to retrieve only the starting
388
+ node without traversal.
389
+
390
+ Returns:
391
+ List of ContextNodes in the subgraph (includes starting node).
392
+ """
393
+ if node_id not in self._graph:
394
+ return []
395
+
396
+ visited: set[str] = set()
397
+ frontier: list[str] = [node_id]
398
+ for _ in range(max(1, depth)):
399
+ next_frontier: list[str] = []
400
+ for nid in frontier:
401
+ if nid in visited:
402
+ continue
403
+ visited.add(nid)
404
+ for _, t in self._graph.out_edges(nid):
405
+ if t not in visited:
406
+ next_frontier.append(t)
407
+ for s, _ in self._graph.in_edges(nid):
408
+ if s not in visited:
409
+ next_frontier.append(s)
410
+ frontier = next_frontier
411
+ # Include final frontier tier — these were reached but not yet expanded
412
+ visited.update(frontier)
413
+
414
+ return [
415
+ ContextNode.from_dict(dict(self._graph.nodes[nid]))
416
+ for nid in visited
417
+ if nid in self._graph
418
+ ]
419
+
420
+ def to_json(self) -> str:
421
+ """Serialize graph to JSON string.
422
+
423
+ Returns:
424
+ JSON string representation of graph.
425
+ """
426
+ nodes = [dict(self._graph.nodes[n]) for n in self._graph.nodes]
427
+ edges = [dict(self._graph.edges[e]) for e in self._graph.edges]
428
+
429
+ return json.dumps({"nodes": nodes, "edges": edges}, indent=2)
430
+
431
+ @classmethod
432
+ def from_json(cls, data: str, root: Path | None = None) -> ContextGraph:
433
+ """Deserialize graph from JSON string.
434
+
435
+ Args:
436
+ data: JSON string.
437
+ root: Project root directory.
438
+
439
+ Returns:
440
+ New ContextGraph instance.
441
+ """
442
+ graph = cls(root=root)
443
+ parsed = json.loads(data)
444
+
445
+ for node_data in parsed.get("nodes", []):
446
+ node = ContextNode.from_dict(node_data)
447
+ graph._graph.add_node(node.id, **node.to_dict())
448
+
449
+ for edge_data in parsed.get("edges", []):
450
+ from_id = edge_data["from_id"]
451
+ to_id = edge_data["to_id"]
452
+ if from_id in graph._graph and to_id in graph._graph:
453
+ graph._graph.add_edge(from_id, to_id, **edge_data)
454
+
455
+ return graph
456
+
457
+ def save(self) -> Path:
458
+ """Save graph to disk.
459
+
460
+ Context nodes (decisions/patterns/issues) → ref-reports/context_graph.json (durable).
461
+ AST nodes (module/class/function/file) → .ref_cache/ast_graph.json (regenerable).
462
+
463
+ Returns:
464
+ Path to primary context file.
465
+ """
466
+ context_nodes = []
467
+ ast_nodes = []
468
+ for n in self._graph.nodes:
469
+ data = dict(self._graph.nodes[n])
470
+ if data.get("origin") == "ast":
471
+ ast_nodes.append(data)
472
+ else:
473
+ context_nodes.append(data)
474
+
475
+ all_edges = [dict(self._graph.edges[e]) for e in self._graph.edges]
476
+
477
+ # Context edges: both endpoints are context nodes
478
+ context_node_ids = {n["id"] for n in context_nodes}
479
+ context_edges = [
480
+ e
481
+ for e in all_edges
482
+ if e["from_id"] in context_node_ids and e["to_id"] in context_node_ids
483
+ ]
484
+ # AST edges: at least one endpoint is an AST node
485
+ ast_edges = [e for e in all_edges if e not in context_edges]
486
+
487
+ self._primary_dir.mkdir(parents=True, exist_ok=True)
488
+ self._primary_file.write_text(
489
+ json.dumps({"nodes": context_nodes, "edges": context_edges}, indent=2)
490
+ )
491
+
492
+ try:
493
+ self._cache_dir.mkdir(parents=True, exist_ok=True)
494
+ ast_cache = self._cache_dir / "ast_graph.json"
495
+ ast_cache.write_text(
496
+ json.dumps({"nodes": ast_nodes, "edges": ast_edges}, indent=2)
497
+ )
498
+ except OSError as e:
499
+ import structlog
500
+
501
+ structlog.get_logger(__name__).warning(
502
+ "ast_graph_cache_save_failed", error=str(e)
503
+ )
504
+
505
+ return self._primary_file
506
+
507
+ @classmethod
508
+ def load(cls, root: Path | None = None) -> ContextGraph:
509
+ """Load graph from disk.
510
+
511
+ Loads context nodes from ref-reports/context_graph.json (durable).
512
+ Merges AST nodes from .ref_cache/ast_graph.json if present (regenerable).
513
+
514
+ .. warning::
515
+ Do not call directly — use get_context_graph().
516
+ Direct calls bypass the singleton and drop in-memory state accumulated
517
+ since the last save, causing context graph update failures (RC5).
518
+
519
+ Args:
520
+ root: Project root directory.
521
+
522
+ Returns:
523
+ Loaded ContextGraph or empty graph if no files exist.
524
+ """
525
+ graph = cls(root=root)
526
+
527
+ # Load durable context nodes
528
+ if graph._primary_file.exists():
529
+ try:
530
+ loaded = cls.from_json(graph._primary_file.read_text(), root=root)
531
+ graph._graph = loaded._graph
532
+ except (json.JSONDecodeError, ValueError, KeyError) as e:
533
+ import structlog
534
+
535
+ structlog.get_logger(__name__).warning(
536
+ "context_graph_primary_load_failed", error=str(e)
537
+ )
538
+
539
+ # Merge AST nodes from cache (optional — missing = needs graph_populate)
540
+ ast_cache = graph._cache_dir / "ast_graph.json"
541
+ if ast_cache.exists():
542
+ try:
543
+ parsed = json.loads(ast_cache.read_text())
544
+ for node_data in parsed.get("nodes", []):
545
+ node = ContextNode.from_dict(node_data)
546
+ if node.id not in graph._graph:
547
+ graph._graph.add_node(node.id, **node.to_dict())
548
+ for edge_data in parsed.get("edges", []):
549
+ f, t = edge_data["from_id"], edge_data["to_id"]
550
+ if f in graph._graph and t in graph._graph:
551
+ graph._graph.add_edge(f, t, **edge_data)
552
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
553
+ import structlog
554
+
555
+ structlog.get_logger(__name__).warning(
556
+ "ast_graph_cache_load_failed", error=str(e)
557
+ )
558
+
559
+ return graph
560
+
561
+ def format_for_prompt_graph(
562
+ self,
563
+ story_id: str | None = None,
564
+ depth: int = 2,
565
+ ) -> str:
566
+ """Build context string via BFS subgraph instead of flat JSONL.
567
+
568
+ Finds story node (or uses all context nodes), BFS to depth, returns
569
+ ranked markdown. Replaces flat chronological JSONL injection.
570
+
571
+ Args:
572
+ story_id: Story node ID to start BFS from. None = all context nodes.
573
+ depth: BFS traversal depth.
574
+
575
+ Returns:
576
+ Formatted markdown string for prompt injection.
577
+ """
578
+ if self._graph.number_of_nodes() == 0:
579
+ return ""
580
+
581
+ # Collect seed node IDs
582
+ seeds: list[str] = []
583
+ if story_id:
584
+ story_nodes = [
585
+ n
586
+ for n in self._graph.nodes
587
+ if self._graph.nodes[n].get("story_id") == story_id
588
+ or self._graph.nodes[n].get("node_type") == "story"
589
+ and story_id in n
590
+ ]
591
+ seeds = story_nodes
592
+
593
+ if not seeds:
594
+ # Fall back: 5 most recent non-AST nodes (timestamp desc).
595
+ # Capped to prevent cross-story context bleed when no active story.
596
+ non_ast = [
597
+ n
598
+ for n in self._graph.nodes
599
+ if self._graph.nodes[n].get("origin") != "ast"
600
+ ]
601
+ non_ast.sort(
602
+ key=lambda n: self._graph.nodes[n].get("timestamp", ""),
603
+ reverse=True,
604
+ )
605
+ seeds = non_ast[:5]
606
+
607
+ if not seeds:
608
+ return ""
609
+
610
+ # BFS from seeds up to depth
611
+ visited: set[str] = set()
612
+ frontier = list(seeds)
613
+ for _ in range(depth):
614
+ next_frontier: list[str] = []
615
+ for node_id in frontier:
616
+ if node_id in visited:
617
+ continue
618
+ visited.add(node_id)
619
+ for _, target in self._graph.out_edges(node_id):
620
+ if target not in visited:
621
+ next_frontier.append(target)
622
+ for source, _ in self._graph.in_edges(node_id):
623
+ if source not in visited:
624
+ next_frontier.append(source)
625
+ frontier = next_frontier
626
+
627
+ visited.update(frontier)
628
+
629
+ # Group by node_type, sort by confidence on incoming edges
630
+ by_type: dict[str, list[dict[str, Any]]] = {}
631
+ for node_id in visited:
632
+ if node_id not in self._graph:
633
+ continue
634
+ data = dict(self._graph.nodes[node_id])
635
+ node_type = data.get("node_type", "unknown")
636
+ if node_type in AST_NODE_TYPES and data.get("origin") == "ast":
637
+ continue # Skip raw AST structural nodes from prompt
638
+ by_type.setdefault(node_type, []).append(data)
639
+
640
+ if not by_type:
641
+ return ""
642
+
643
+ lines: list[str] = ["\n--- SESSION CONTEXT (graph-based) ---\n"]
644
+ priority = ["decision", "blocker", "pattern", "issue", "story", "note"]
645
+ ordered = sorted(
646
+ by_type.keys(), key=lambda k: priority.index(k) if k in priority else 99
647
+ )
648
+
649
+ for node_type in ordered:
650
+ nodes_of_type = by_type[node_type]
651
+ lines.append(f"**{node_type.title()}s:**")
652
+ for n in nodes_of_type[:8]: # Cap per type
653
+ sid = f" [{n.get('story_id')}]" if n.get("story_id") else ""
654
+ lines.append(f"• {n.get('content', '')[:120]}{sid}")
655
+ lines.append("")
656
+
657
+ lines.append("--- END CONTEXT ---\n")
658
+ return "\n".join(lines)
659
+
660
+ def to_html(self) -> str:
661
+ """Generate self-contained interactive HTML graph viewer (vis.js CDN).
662
+
663
+ Returns:
664
+ HTML string. Write to ref-reports/graph.html to open in browser.
665
+ """
666
+ graph_data = self.to_json()
667
+ parsed = json.loads(graph_data)
668
+
669
+ vis_nodes = []
670
+ color_map = {
671
+ "decision": "#4A90E2",
672
+ "pattern": "#7ED321",
673
+ "issue": "#D0021B",
674
+ "story": "#9B59B6",
675
+ "agent_session": "#F5A623",
676
+ "module": "#50E3C2",
677
+ "class": "#B8E986",
678
+ "function": "#C8C8C8",
679
+ "file": "#8B572A",
680
+ }
681
+ for node in parsed.get("nodes", []):
682
+ vis_nodes.append(
683
+ {
684
+ "id": node["id"],
685
+ "label": node.get("content", node["id"])[:40],
686
+ "title": node.get("content", ""),
687
+ "color": color_map.get(node.get("node_type", ""), "#888888"),
688
+ "group": node.get("node_type", "unknown"),
689
+ }
690
+ )
691
+
692
+ vis_edges = []
693
+ for i, edge in enumerate(parsed.get("edges", [])):
694
+ vis_edges.append(
695
+ {
696
+ "id": i,
697
+ "from": edge["from_id"],
698
+ "to": edge["to_id"],
699
+ "label": edge.get("edge_type", ""),
700
+ "arrows": "to",
701
+ "color": {"opacity": float(edge.get("confidence", 1.0))},
702
+ }
703
+ )
704
+
705
+ nodes_json = json.dumps(vis_nodes)
706
+ edges_json = json.dumps(vis_edges)
707
+
708
+ return f"""<!DOCTYPE html>
709
+ <html lang="en">
710
+ <head>
711
+ <meta charset="UTF-8">
712
+ <title>REF Context Graph</title>
713
+ <script src="https://cdn.jsdelivr.net/npm/vis-network@9/standalone/umd/vis-network.min.js"></script>
714
+ <style>
715
+ body {{ margin: 0; background: #1a1a2e; font-family: monospace; color: #eee; }}
716
+ #graph {{ width: 100vw; height: 90vh; border: none; }}
717
+ #info {{ padding: 8px 16px; background: #16213e; font-size: 12px; }}
718
+ #legend {{ display: flex; gap: 12px; flex-wrap: wrap; padding: 6px 16px; background: #0f3460; font-size: 11px; }}
719
+ .dot {{ width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }}
720
+ </style>
721
+ </head>
722
+ <body>
723
+ <div id="info">REF Context Graph &nbsp;|&nbsp; Nodes: {len(vis_nodes)} &nbsp;|&nbsp; Edges: {len(vis_edges)}</div>
724
+ <div id="legend">
725
+ {"".join(f'<span><span class="dot" style="background:{v}"></span>{k}</span>' for k, v in color_map.items())}
726
+ </div>
727
+ <div id="graph"></div>
728
+ <script>
729
+ const nodes = new vis.DataSet({nodes_json});
730
+ const edges = new vis.DataSet({edges_json});
731
+ const container = document.getElementById("graph");
732
+ const network = new vis.Network(container, {{ nodes, edges }}, {{
733
+ physics: {{ stabilization: {{ iterations: 150 }}, barnesHut: {{ gravitationalConstant: -8000 }} }},
734
+ edges: {{ font: {{ size: 10, color: "#aaa" }}, smooth: {{ type: "curvedCW", roundness: 0.2 }} }},
735
+ nodes: {{ font: {{ color: "#fff", size: 12 }}, shape: "dot", size: 12 }},
736
+ interaction: {{ hover: true, tooltipDelay: 100 }},
737
+ }});
738
+ network.on("click", p => {{
739
+ if (p.nodes.length) {{
740
+ const n = nodes.get(p.nodes[0]);
741
+ document.getElementById("info").textContent = n.title || n.label;
742
+ }}
743
+ }});
744
+ </script>
745
+ </body>
746
+ </html>"""
747
+
748
+ def to_visualization_json(self) -> str:
749
+ """Export graph in format suitable for react-flow visualization.
750
+
751
+ Returns:
752
+ JSON string with nodes and edges for frontend.
753
+ """
754
+ viz_nodes = []
755
+ for node_id in self._graph.nodes:
756
+ data = self._graph.nodes[node_id]
757
+ viz_nodes.append(
758
+ {
759
+ "id": node_id,
760
+ "type": data.get("node_type", "decision"),
761
+ "data": {
762
+ "label": data.get("content", "")[:50],
763
+ "fullContent": data.get("content", ""),
764
+ "nodeType": data.get("node_type", ""),
765
+ "storyId": data.get("story_id", ""),
766
+ "timestamp": data.get("timestamp", ""),
767
+ },
768
+ "position": {"x": 0, "y": 0}, # Layout computed by frontend
769
+ }
770
+ )
771
+
772
+ viz_edges = []
773
+ for idx, (source, target) in enumerate(self._graph.edges):
774
+ data = self._graph.edges[(source, target)]
775
+ viz_edges.append(
776
+ {
777
+ "id": f"e{idx}",
778
+ "source": source,
779
+ "target": target,
780
+ "label": data.get("edge_type", ""),
781
+ "type": "smoothstep",
782
+ }
783
+ )
784
+
785
+ return json.dumps({"nodes": viz_nodes, "edges": viz_edges}, indent=2)
786
+
787
+
788
+ # Singleton instance for global access
789
+ _context_graph: ContextGraph | None = None
790
+ _watcher_started: bool = False
791
+
792
+
793
+ def get_context_graph(root: Path | None = None) -> ContextGraph:
794
+ """Get or create the global context graph instance.
795
+
796
+ On first initialisation, also auto-starts the AST file watcher (RC2) so
797
+ that incremental graph updates fire on every file save without requiring an
798
+ explicit context(action="graph_watch") call.
799
+
800
+ Args:
801
+ root: Optional project root. Only used on first call.
802
+
803
+ Returns:
804
+ Global ContextGraph instance.
805
+ """
806
+ global _context_graph, _watcher_started
807
+ if _context_graph is None:
808
+ _context_graph = ContextGraph.load(root)
809
+
810
+ if not _watcher_started:
811
+ _watcher_started = True
812
+ try:
813
+ import threading
814
+
815
+ from ref_agents.server import _ast_observers, _context_graph_watch
816
+
817
+ _root_str = (
818
+ str(_context_graph._root)
819
+ if hasattr(_context_graph, "_root")
820
+ else ""
821
+ )
822
+ if _root_str and _root_str not in _ast_observers:
823
+ threading.Thread(
824
+ target=_context_graph_watch,
825
+ args=(_root_str,),
826
+ daemon=True,
827
+ name="ref-ast-watcher-auto",
828
+ ).start()
829
+ except (ImportError, OSError, RuntimeError):
830
+ # Non-fatal: watcher is a best-effort optimisation
831
+ pass
832
+
833
+ return _context_graph
834
+
835
+
836
+ def reset_context_graph() -> None:
837
+ """Reset the global context graph instance. Primarily for testing."""
838
+ global _context_graph
839
+ _context_graph = None