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.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|