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,550 @@
1
+ """Context Persistence Manager for REF.
2
+
3
+ This module provides hierarchical context storage across sessions:
4
+ - Story level: Decisions, blockers, patterns for a single story
5
+ - Epic level: Shared patterns across related stories
6
+ - Project level: Long-term conventions and recurring fixes
7
+
8
+ Context is stored as JSONL files in ref-reports/context/ directory.
9
+ """
10
+
11
+ import json
12
+ from dataclasses import asdict, dataclass
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import structlog
18
+
19
+ from ref_agents.utils.git_utils import get_current_user_id, get_ref_reports_dir
20
+
21
+ logger = structlog.get_logger(__name__)
22
+
23
+ # Default limits for context injection
24
+ DEFAULT_LIMITS = {
25
+ "story": 10,
26
+ "epic": 5,
27
+ "project": 3,
28
+ }
29
+
30
+ # Max content length per entry type (chars). Enforced at write time.
31
+ # NOTE: Agents must summarise to one sentence before storing — rationale belongs in ADRs/CODE_MAP.
32
+ MAX_CONTENT_LENGTH: dict[str, int] = {
33
+ "decision": 200,
34
+ "pattern": 200,
35
+ "blocker": 150,
36
+ "note": 100,
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class ContextEntry:
42
+ """Single context entry.
43
+
44
+ Attributes:
45
+ timestamp: ISO format timestamp.
46
+ entry_type: decision, blocker, pattern, or note.
47
+ content: Human-readable description.
48
+ user: Who created the entry.
49
+ metadata: Optional additional data.
50
+ """
51
+
52
+ timestamp: str
53
+ entry_type: str
54
+ content: str
55
+ user: str
56
+ metadata: dict[str, Any] | None = None
57
+
58
+
59
+ class ContextManager:
60
+ """Manages hierarchical context persistence.
61
+
62
+ Attributes:
63
+ root: Root directory for context files.
64
+ limits: Max entries to load per layer.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ root: Path | None = None,
70
+ limits: dict[str, int] | None = None,
71
+ ) -> None:
72
+ """Initialize context manager.
73
+
74
+ Args:
75
+ root: Context storage directory. Defaults to {project_root}/.ref_cache/context/.
76
+ limits: Max entries per layer. Defaults to story=10, epic=5, project=3.
77
+
78
+ Raises:
79
+ RuntimeError: If project root cannot be determined.
80
+ """
81
+ if root is None:
82
+ root = get_ref_reports_dir() / "context"
83
+ self.root = root
84
+ self.limits = limits or DEFAULT_LIMITS
85
+
86
+ def _get_file_path(self, layer: str, identifier: str | None = None) -> Path:
87
+ """Get file path for a context layer.
88
+
89
+ Args:
90
+ layer: One of 'story', 'epic', 'project', or 'incident'.
91
+ identifier: Story/epic/incident ID. Required for story/epic/incident, ignored for project.
92
+
93
+ Returns:
94
+ Path to the JSONL file.
95
+
96
+ Raises:
97
+ ValueError: If layer is invalid or identifier missing for story/epic.
98
+ """
99
+ if layer == "project":
100
+ return self.root / "project.jsonl"
101
+ elif layer in ("story", "epic", "incident"):
102
+ if not identifier:
103
+ raise ValueError(f"{layer} layer requires identifier")
104
+ return self.root / f"{layer}_{identifier}.jsonl"
105
+ else:
106
+ raise ValueError(
107
+ f"Invalid layer: {layer}. Use story, epic, project, or incident."
108
+ )
109
+
110
+ def _ensure_dir(self) -> None:
111
+ """Create context directory if it doesn't exist."""
112
+ self.root.mkdir(parents=True, exist_ok=True)
113
+
114
+ def add_entry( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
115
+ self,
116
+ layer: str,
117
+ entry_type: str,
118
+ content: str,
119
+ identifier: str | None = None,
120
+ metadata: dict[str, Any] | None = None,
121
+ ) -> ContextEntry:
122
+ """Add a context entry.
123
+
124
+ Args:
125
+ layer: One of 'story', 'epic', or 'project'.
126
+ entry_type: Type of entry (decision, blocker, pattern, note).
127
+ content: Human-readable description.
128
+ identifier: Story/epic ID. Required for story/epic layers.
129
+ metadata: Optional additional data.
130
+
131
+ Returns:
132
+ The created ContextEntry.
133
+
134
+ Raises:
135
+ ValueError: If layer invalid or identifier missing.
136
+ """
137
+ self._ensure_dir()
138
+ file_path = self._get_file_path(layer, identifier)
139
+
140
+ max_len = MAX_CONTENT_LENGTH.get(entry_type, 200)
141
+ if len(content) > max_len:
142
+ raise ValueError(
143
+ f"Content too long ({len(content)} chars, max {max_len} for '{entry_type}'). "
144
+ "Summarise to one sentence and retry."
145
+ )
146
+
147
+ entry = ContextEntry(
148
+ timestamp=datetime.now().isoformat(),
149
+ entry_type=entry_type,
150
+ content=content,
151
+ user=get_current_user_id(),
152
+ metadata=metadata,
153
+ )
154
+
155
+ try:
156
+ with open(file_path, "a", encoding="utf-8") as f:
157
+ f.write(json.dumps(asdict(entry)) + "\n")
158
+
159
+ logger.info(
160
+ "context_entry_added",
161
+ layer=layer,
162
+ entry_type=entry_type,
163
+ identifier=identifier,
164
+ )
165
+ except OSError as e:
166
+ logger.error("context_write_failed", error=str(e), path=str(file_path))
167
+ raise
168
+
169
+ # Auto-sync to context graph (non-blocking — JSONL write already succeeded)
170
+ self._sync_to_graph(entry, identifier)
171
+
172
+ return entry
173
+
174
+ def _sync_to_graph(self, entry: ContextEntry, identifier: str | None) -> None:
175
+ """Mirror a JSONL entry as a context graph node.
176
+
177
+ Args:
178
+ entry: The entry that was just persisted.
179
+ identifier: Story/epic ID if applicable.
180
+ """
181
+ try:
182
+ from datetime import datetime, timezone
183
+
184
+ from ref_agents.tools.context_graph import ContextNode, get_context_graph
185
+
186
+ # Map JSONL entry types → valid ContextGraph node types.
187
+ # "blocker" and "note" are not valid NodeType values; remap them.
188
+ _ENTRY_TYPE_MAP: dict[str, str] = {
189
+ "blocker": "issue",
190
+ "note": "decision",
191
+ "pattern": "pattern",
192
+ "decision": "decision",
193
+ }
194
+ node_type = _ENTRY_TYPE_MAP.get(entry.entry_type, "decision")
195
+
196
+ graph = get_context_graph()
197
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
198
+ node_id = f"{node_type}-{ts}"
199
+
200
+ node = ContextNode(
201
+ id=node_id,
202
+ node_type=node_type, # type: ignore[arg-type]
203
+ content=entry.content,
204
+ story_id=identifier or "",
205
+ origin="context",
206
+ metadata={"user": entry.user, **(entry.metadata or {})},
207
+ )
208
+ graph.add_node(node)
209
+ graph.save()
210
+ except (OSError, ValueError) as e:
211
+ # Non-blocking: JSONL is source of truth; graph sync failure is recoverable
212
+ logger.warning("context_graph_sync_failed", error=str(e))
213
+
214
+ def get_entries(
215
+ self,
216
+ layer: str,
217
+ identifier: str | None = None,
218
+ limit: int | None = None,
219
+ ttl_by_type: dict[str, int] | None = None,
220
+ ) -> list[ContextEntry]:
221
+ """Get context entries for a layer.
222
+
223
+ Args:
224
+ layer: One of 'story', 'epic', or 'project'.
225
+ identifier: Story/epic ID. Required for story/epic layers.
226
+ limit: Max entries to return (most recent). Defaults to layer limit.
227
+ ttl_by_type: Optional dict of entry_type -> max age in days.
228
+ Only types listed are evicted; omitted types are always kept.
229
+ Example: {"note": 3} evicts note entries older than 3 days.
230
+
231
+ Returns:
232
+ List of ContextEntry objects, most recent first.
233
+ """
234
+ try:
235
+ file_path = self._get_file_path(layer, identifier)
236
+ except ValueError:
237
+ return []
238
+
239
+ if not file_path.exists():
240
+ return []
241
+
242
+ entries: list[ContextEntry] = []
243
+ try:
244
+ with open(file_path, "r", encoding="utf-8") as f:
245
+ for line in f:
246
+ line = line.strip()
247
+ if line:
248
+ data = json.loads(line)
249
+ entries.append(ContextEntry(**data))
250
+ except (OSError, json.JSONDecodeError) as e:
251
+ logger.warning("context_read_failed", error=str(e), path=str(file_path))
252
+ return []
253
+
254
+ if ttl_by_type:
255
+ now = datetime.now()
256
+ kept: list[ContextEntry] = []
257
+ for entry in entries:
258
+ max_age = ttl_by_type.get(entry.entry_type)
259
+ if max_age is None:
260
+ kept.append(entry)
261
+ else:
262
+ try:
263
+ age_days = (now - datetime.fromisoformat(entry.timestamp)).days
264
+ if age_days <= max_age:
265
+ kept.append(entry)
266
+ except ValueError:
267
+ kept.append(entry) # unparseable timestamp → keep
268
+ entries = kept
269
+
270
+ max_entries = limit or self.limits.get(layer, 10)
271
+ return entries[-max_entries:][::-1] # Reverse for most recent first
272
+
273
+ def get_combined_context(
274
+ self,
275
+ story_id: str | None = None,
276
+ epic_id: str | None = None,
277
+ ) -> dict[str, list[ContextEntry]]:
278
+ """Get combined context from all applicable layers.
279
+
280
+ Applies TTL eviction for 'note' entries (3-day TTL). All other types
281
+ (decision, pattern, blocker) are permanent — required for cross-agent handoff.
282
+
283
+ Args:
284
+ story_id: Current story ID (optional).
285
+ epic_id: Current epic ID (optional).
286
+
287
+ Returns:
288
+ Dict with 'project', 'epic', 'story' keys containing entries.
289
+ """
290
+ # NOTE: note TTL=3 days — ephemeral observations; decision/pattern/blocker: permanent
291
+ _note_ttl: dict[str, int] = {"note": 3}
292
+
293
+ result: dict[str, list[ContextEntry]] = {
294
+ "project": self.get_entries("project", ttl_by_type=_note_ttl),
295
+ "epic": [],
296
+ "story": [],
297
+ }
298
+
299
+ if epic_id:
300
+ result["epic"] = self.get_entries("epic", epic_id, ttl_by_type=_note_ttl)
301
+
302
+ if story_id:
303
+ result["story"] = self.get_entries("story", story_id, ttl_by_type=_note_ttl)
304
+
305
+ return result
306
+
307
+ def format_for_prompt(
308
+ self,
309
+ story_id: str | None = None,
310
+ epic_id: str | None = None,
311
+ ) -> str:
312
+ """Format context for injection into agent prompts.
313
+
314
+ Args:
315
+ story_id: Current story ID (optional).
316
+ epic_id: Current epic ID (optional).
317
+
318
+ Returns:
319
+ Formatted markdown string for prompt injection.
320
+ """
321
+ context = self.get_combined_context(story_id, epic_id)
322
+
323
+ total_entries = sum(len(v) for v in context.values())
324
+ if total_entries == 0:
325
+ return ""
326
+
327
+ lines: list[str] = [
328
+ "\n--- SESSION CONTEXT (from previous sessions) ---\n",
329
+ ]
330
+
331
+ # Project context
332
+ if context["project"]:
333
+ lines.append("**📁 Project Conventions:**")
334
+ for entry in context["project"]:
335
+ lines.append(f"• [{entry.entry_type}] {entry.content}")
336
+ lines.append("")
337
+
338
+ # Epic context
339
+ if context["epic"]:
340
+ lines.append(f"**📦 Epic ({epic_id}) Patterns:**")
341
+ for entry in context["epic"]:
342
+ lines.append(f"• [{entry.entry_type}] {entry.content}")
343
+ lines.append("")
344
+
345
+ # Story context
346
+ if context["story"]:
347
+ lines.append(f"**📝 Story ({story_id}) History:**")
348
+ for entry in context["story"]:
349
+ lines.append(f"• [{entry.entry_type}] {entry.content} ({entry.user})")
350
+ lines.append("")
351
+
352
+ lines.append("--- END CONTEXT ---\n")
353
+
354
+ result = "\n".join(lines)
355
+ logger.warning(
356
+ "context_injected",
357
+ story_id=story_id,
358
+ epic_id=epic_id,
359
+ entries=total_entries,
360
+ bytes=len(result.encode()),
361
+ )
362
+ return result
363
+
364
+ def prune(
365
+ self,
366
+ layer: str,
367
+ identifier: str | None = None,
368
+ keep: int = 20,
369
+ ) -> int:
370
+ """Prune old entries from a context file.
371
+
372
+ Args:
373
+ layer: One of 'story', 'epic', or 'project'.
374
+ identifier: Story/epic ID. Required for story/epic layers.
375
+ keep: Number of most recent entries to keep.
376
+
377
+ Returns:
378
+ Number of entries removed.
379
+ """
380
+ file_path = self._get_file_path(layer, identifier)
381
+
382
+ if not file_path.exists():
383
+ return 0
384
+
385
+ entries: list[str] = []
386
+ try:
387
+ with open(file_path, "r", encoding="utf-8") as f:
388
+ entries = [line.strip() for line in f if line.strip()]
389
+ except OSError as e:
390
+ logger.error("context_prune_read_failed", error=str(e))
391
+ return 0
392
+
393
+ if len(entries) <= keep:
394
+ return 0
395
+
396
+ removed = len(entries) - keep
397
+ kept_entries = entries[-keep:]
398
+
399
+ try:
400
+ with open(file_path, "w", encoding="utf-8") as f:
401
+ for entry in kept_entries:
402
+ f.write(entry + "\n")
403
+
404
+ logger.info(
405
+ "context_pruned",
406
+ layer=layer,
407
+ identifier=identifier,
408
+ removed=removed,
409
+ )
410
+ except OSError as e:
411
+ logger.error("context_prune_write_failed", error=str(e))
412
+ return 0
413
+
414
+ return removed
415
+
416
+ def clear(self, layer: str, identifier: str | None = None) -> bool:
417
+ """Clear all entries for a context layer.
418
+
419
+ Args:
420
+ layer: One of 'story', 'epic', or 'project'.
421
+ identifier: Story/epic ID. Required for story/epic layers.
422
+
423
+ Returns:
424
+ True if cleared successfully, False otherwise.
425
+ """
426
+ file_path = self._get_file_path(layer, identifier)
427
+
428
+ if not file_path.exists():
429
+ return True
430
+
431
+ try:
432
+ file_path.unlink()
433
+ logger.info("context_cleared", layer=layer, identifier=identifier)
434
+ return True
435
+ except OSError as e:
436
+ logger.error("context_clear_failed", error=str(e))
437
+ return False
438
+
439
+
440
+ # Convenience functions for tool integration
441
+
442
+ # Valid actions for manage_context
443
+ CONTEXT_ACTIONS = ("add", "get", "clear")
444
+
445
+
446
+ def manage_context( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
447
+ action: str,
448
+ layer: str = "",
449
+ entry_type: str = "",
450
+ content: str = "",
451
+ identifier: str = "",
452
+ story_id: str = "",
453
+ epic_id: str = "",
454
+ ) -> str:
455
+ """Unified context management tool.
456
+
457
+ Args:
458
+ action: One of: add, get, clear.
459
+ layer: Context layer (story, epic, project) - for add/clear.
460
+ entry_type: Type: decision, blocker, pattern, note - for add.
461
+ content: Description of the context - for add.
462
+ identifier: Story/epic ID - for add/clear (required for story/epic layers).
463
+ story_id: Current story ID - for get.
464
+ epic_id: Current epic ID - for get.
465
+
466
+ Returns:
467
+ Success/error message or formatted context.
468
+ """
469
+ if action not in CONTEXT_ACTIONS:
470
+ return f"❌ Invalid action. Use: {', '.join(CONTEXT_ACTIONS)}"
471
+
472
+ manager = ContextManager()
473
+
474
+ if action == "add":
475
+ if not layer:
476
+ return "❌ 'layer' required for add action"
477
+ if not entry_type:
478
+ return "❌ 'entry_type' required for add action"
479
+ if not content:
480
+ return "❌ 'content' required for add action"
481
+
482
+ try:
483
+ entry = manager.add_entry(
484
+ layer, entry_type, content, identifier if identifier else None
485
+ )
486
+ return f"✅ Context added: [{entry.entry_type}] {entry.content}"
487
+ except ValueError as e:
488
+ return f"❌ Error: {e}"
489
+ except OSError as e:
490
+ return f"❌ Failed to save: {e}"
491
+
492
+ if action == "get":
493
+ formatted = manager.format_for_prompt(
494
+ story_id if story_id else None,
495
+ epic_id if epic_id else None,
496
+ )
497
+ if not formatted:
498
+ return "📭 No context found for this session."
499
+ return formatted
500
+
501
+ else: # action == "clear"
502
+ if not layer:
503
+ return "❌ 'layer' required for clear action"
504
+ try:
505
+ success = manager.clear(layer, identifier if identifier else None)
506
+ if success:
507
+ return f"✅ Context cleared for {layer}"
508
+ return f"❌ Failed to clear context for {layer}"
509
+ except ValueError as e:
510
+ return f"❌ Error: {e}"
511
+
512
+
513
+ # Backward compatibility wrappers for tests and server.py
514
+ def add_context(
515
+ layer: str,
516
+ entry_type: str,
517
+ content: str,
518
+ identifier: str = "",
519
+ ) -> str:
520
+ """Add context entry (backward compatibility wrapper).
521
+
522
+ Args:
523
+ layer: Context layer (story, epic, project).
524
+ entry_type: Type: decision, blocker, pattern, note.
525
+ content: Description of the context.
526
+ identifier: Story/epic ID (required for story/epic layers).
527
+
528
+ Returns:
529
+ Success/error message.
530
+ """
531
+ return manage_context(
532
+ action="add",
533
+ layer=layer,
534
+ entry_type=entry_type,
535
+ content=content,
536
+ identifier=identifier,
537
+ )
538
+
539
+
540
+ def get_context(story_id: str = "", epic_id: str = "") -> str:
541
+ """Get formatted context (backward compatibility wrapper).
542
+
543
+ Args:
544
+ story_id: Current story ID.
545
+ epic_id: Current epic ID.
546
+
547
+ Returns:
548
+ Formatted context string or empty message.
549
+ """
550
+ return manage_context(action="get", story_id=story_id, epic_id=epic_id)
@@ -0,0 +1,121 @@
1
+ from datetime import datetime, timezone
2
+ from pathlib import Path
3
+
4
+ import structlog
5
+
6
+ from ref_agents.constants import Status, AVAILABLE_AGENTS
7
+ from ref_agents.session import SessionManager
8
+ from ref_agents.tools import codemap
9
+
10
+ logger = structlog.get_logger(__name__)
11
+
12
+
13
+ def _format_available_agents_numbered() -> str:
14
+ """Private helper to format agent list as numbered list for activation."""
15
+ output = "🚀 REF (Relay Engineering Framework) - Agent Selection\n\n"
16
+ output += "Please select your agent by entering the corresponding number:\n\n"
17
+
18
+ agents_list = list(AVAILABLE_AGENTS.items())
19
+ for idx, (agent, desc) in enumerate(agents_list, start=1):
20
+ output += f"{idx}. {desc}\n"
21
+ output += f" (Agent: `{agent}`)\n\n"
22
+
23
+ output += f"Enter the number (1-{len(agents_list)}) to activate your agent."
24
+ return output
25
+
26
+
27
+ def set_project_root_impl(path: str) -> str:
28
+ """Implementation of set_project_root tool."""
29
+ p = Path(path).resolve()
30
+ if not p.exists():
31
+ return f"{Status.ERROR} Directory {path} does not exist"
32
+
33
+ SessionManager.get().project_root = p
34
+ logger.info("project_root_set", path=str(p))
35
+
36
+ return f"{Status.SUCCESS} Project root set to: `{p}`"
37
+
38
+
39
+ def identify_agent_impl(context_history: str = "") -> str:
40
+ """Implementation of identify_agent tool."""
41
+ active_agent = SessionManager.get().active_agent
42
+
43
+ if active_agent:
44
+ return f"{Status.SUCCESS} Agent is active: `{active_agent}`. Proceed with REF Protocol."
45
+
46
+ agents_output = _format_available_agents_numbered()
47
+
48
+ return (
49
+ "⛔ STOP. No REF Agent is active.\n"
50
+ "You MUST ask the user:\n\n"
51
+ f"{agents_output}\n\n"
52
+ 'Once they answer, call agent(action="activate", name=agent_name) or agent(action="activate", name=number).'
53
+ )
54
+
55
+
56
+ def find_project_root() -> Path | None:
57
+ """Find the project root by looking for CODE_MAP.md.
58
+ Prioritizes session state -> CWD traversal.
59
+ """
60
+ # 1. Prioritize session state
61
+ root = SessionManager.get().project_root
62
+ if root:
63
+ return root
64
+
65
+ # 2. Fallback to CWD traversal
66
+ current = Path.cwd()
67
+ for path in [current, *current.parents]:
68
+ if (path / "CODE_MAP.md").exists():
69
+ return path
70
+ return None
71
+
72
+
73
+ def generate_codemap_wrapper(directory: str) -> str:
74
+ """Wrapper that also sets project root context and creates context graph node."""
75
+ result = codemap.generate_codemap_tool(directory)
76
+
77
+ # Auto-set project root if successful
78
+ if "Generated" in result or "exists" in result:
79
+ p = Path(directory).resolve()
80
+ if p.exists():
81
+ SessionManager.get().project_root = p
82
+ logger.info("project_root_auto_set", path=str(p))
83
+
84
+ # Create context graph node for CODE_MAP generation
85
+ try:
86
+ from ref_agents.tools.context_graph import (
87
+ ContextNode,
88
+ get_context_graph,
89
+ )
90
+
91
+ active_agent = SessionManager.get().active_agent
92
+ graph = get_context_graph(p)
93
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
94
+ node_id = f"decision-{ts}"
95
+
96
+ content = f"Generated CODE_MAP.md for {p.name}"
97
+ node = ContextNode(
98
+ id=node_id,
99
+ node_type="decision",
100
+ content=content,
101
+ metadata={"directory": str(p), "agent": active_agent or "unknown"},
102
+ )
103
+ graph.add_node(node)
104
+
105
+ # Link to most recent agent_session if available
106
+ agent_sessions = graph.get_nodes_by_type("agent_session")
107
+ if agent_sessions:
108
+ latest_session = max(agent_sessions, key=lambda n: n.timestamp)
109
+ graph.add_edge(node_id, latest_session.id, "discovered_by")
110
+
111
+ graph.save()
112
+ logger.info(
113
+ "codemap_decision_node_created", node_id=node_id, directory=str(p)
114
+ )
115
+ except (OSError, ValueError, ImportError) as e:
116
+ # Non-blocking: log error but don't fail codemap generation
117
+ logger.warning(
118
+ "context_graph_node_failed", operation="codemap", error=str(e)
119
+ )
120
+
121
+ return result