onecoder 0.0.2__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 (73) hide show
  1. onecoder/agent.py +95 -0
  2. onecoder/agentic_tool_search/__init__.py +0 -0
  3. onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
  4. onecoder/agentic_tool_search/registry.py +33 -0
  5. onecoder/agents/__init__.py +7 -0
  6. onecoder/agents/documentation_agent.py +12 -0
  7. onecoder/agents/file_reader_agent.py +19 -0
  8. onecoder/agents/file_writer_agent.py +19 -0
  9. onecoder/agents/orchestrator_agent.py +51 -0
  10. onecoder/agents/refactoring_agent.py +12 -0
  11. onecoder/agents/research_agent.py +31 -0
  12. onecoder/agents/task_suggestion_agent.py +88 -0
  13. onecoder/alignment.py +236 -0
  14. onecoder/api.py +162 -0
  15. onecoder/api_client.py +112 -0
  16. onecoder/backends/base.py +22 -0
  17. onecoder/backends/local_tui.py +65 -0
  18. onecoder/blackboard.py +102 -0
  19. onecoder/cli.py +108 -0
  20. onecoder/commands/__init__.py +1 -0
  21. onecoder/commands/auth.py +78 -0
  22. onecoder/commands/ci.py +29 -0
  23. onecoder/commands/delegate.py +557 -0
  24. onecoder/commands/doctor.py +40 -0
  25. onecoder/commands/issue.py +136 -0
  26. onecoder/commands/logs.py +45 -0
  27. onecoder/commands/project.py +270 -0
  28. onecoder/commands/server.py +170 -0
  29. onecoder/config_manager.py +87 -0
  30. onecoder/constants.py +9 -0
  31. onecoder/diagnostics/__init__.py +2 -0
  32. onecoder/diagnostics/env_scan.py +207 -0
  33. onecoder/discovery.py +101 -0
  34. onecoder/distillation.py +236 -0
  35. onecoder/evaluation/__init__.py +1 -0
  36. onecoder/evaluation/ttu.py +176 -0
  37. onecoder/governance/__init__.py +0 -0
  38. onecoder/governance/probllm.py +91 -0
  39. onecoder/hooks.py +74 -0
  40. onecoder/ipc_auth.py +200 -0
  41. onecoder/issues.py +188 -0
  42. onecoder/jules_client.py +343 -0
  43. onecoder/knowledge.py +106 -0
  44. onecoder/llm.py +61 -0
  45. onecoder/logger.py +42 -0
  46. onecoder/metrics.py +129 -0
  47. onecoder/models/delegation.py +46 -0
  48. onecoder/onboarding.py +264 -0
  49. onecoder/review.py +233 -0
  50. onecoder/services/delegation_service.py +209 -0
  51. onecoder/services/validation_service.py +104 -0
  52. onecoder/sessions.py +186 -0
  53. onecoder/sprint_collector.py +165 -0
  54. onecoder/sync.py +167 -0
  55. onecoder/tmux.py +86 -0
  56. onecoder/tools/__init__.py +10 -0
  57. onecoder/tools/executor.py +53 -0
  58. onecoder/tools/external_tools.py +106 -0
  59. onecoder/tools/file_tools.py +77 -0
  60. onecoder/tools/interface.py +25 -0
  61. onecoder/tools/jules_tools.py +122 -0
  62. onecoder/tools/kit_tools.py +122 -0
  63. onecoder/tools/registry.py +32 -0
  64. onecoder/tui/__init__.py +5 -0
  65. onecoder/tui/app.py +263 -0
  66. onecoder/tui/commands.py +150 -0
  67. onecoder/tui/widgets.py +92 -0
  68. onecoder/worktree.py +186 -0
  69. onecoder-0.0.2.dist-info/METADATA +17 -0
  70. onecoder-0.0.2.dist-info/RECORD +73 -0
  71. onecoder-0.0.2.dist-info/WHEEL +5 -0
  72. onecoder-0.0.2.dist-info/entry_points.txt +2 -0
  73. onecoder-0.0.2.dist-info/top_level.txt +1 -0
onecoder/issues.py ADDED
@@ -0,0 +1,188 @@
1
+ import os
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Dict, Any, List, Optional
6
+ from datetime import datetime
7
+
8
+ class IssueManager:
9
+ def __init__(self, repo_root: Optional[Path] = None):
10
+ self.repo_root = repo_root or self._find_project_root()
11
+ self.issues_dir = self.repo_root / ".issues"
12
+ self._ensure_dir()
13
+
14
+ def _find_project_root(self) -> Path:
15
+ """Find the project root directory (containing .git)."""
16
+ current = Path.cwd()
17
+ while current != current.parent:
18
+ if (current / ".git").exists():
19
+ return current
20
+ current = current.parent
21
+ return Path.cwd() # Fallback to current directory
22
+
23
+ def _ensure_dir(self):
24
+ self.issues_dir.mkdir(parents=True, exist_ok=True)
25
+
26
+ def get_next_id(self) -> str:
27
+ """Find the next incremental issue ID (e.g. 017)."""
28
+ max_id = 0
29
+ for item in self.issues_dir.iterdir():
30
+ if item.is_file() and item.suffix == ".md":
31
+ match = re.match(r"^(\d{3})-", item.name)
32
+ if match:
33
+ max_id = max(max_id, int(match.group(1)))
34
+ return f"{max_id + 1:03d}"
35
+
36
+ def create_from_telemetry(self, telemetry_data: Dict[str, Any], title: Optional[str] = None) -> Path:
37
+ """Generate a markdown issue file from a single telemetry entry."""
38
+ issue_id = self.get_next_id()
39
+
40
+ # Clean title for filename
41
+ raw_title = title or telemetry_data.get("message", "unhandled-failure")
42
+ clean_title = re.sub(r"[^a-z0-9]+", "-", raw_title.lower()).strip("-")
43
+ filename = f"{issue_id}-{clean_title}.md"
44
+
45
+ issue_path = self.issues_dir / filename
46
+
47
+ content = self._generate_markdown(issue_id, telemetry_data, raw_title)
48
+
49
+ with open(issue_path, "w") as f:
50
+ f.write(content)
51
+
52
+ return issue_path
53
+
54
+ def _generate_markdown(self, issue_id: str, data: Dict[str, Any], title: str) -> str:
55
+ now = datetime.fromisoformat(data.get("timestamp", datetime.now().isoformat())).strftime("%Y-%m-%d")
56
+
57
+ template = f"""# Issue: {title}
58
+
59
+ ## Status
60
+ 🔴 **Open** - Discovered on {now}
61
+
62
+ ## Severity
63
+ **Medium** - Automatically captured failure
64
+
65
+ ## Description
66
+ {data.get('message', 'No description provided.')}
67
+
68
+ ## Steps to Reproduce
69
+ Automatically captured during CLI execution:
70
+ ```bash
71
+ onecoder {' '.join(data.get('context', {}).get('command_args', []))}
72
+ ```
73
+
74
+ ## Actual Behavior
75
+ Error Type: `{data.get('error_type')}`
76
+ Message: `{data.get('message')}`
77
+
78
+ Stack Trace:
79
+ ```python
80
+ {data.get('stack_trace', 'No stack trace available.')}
81
+ ```
82
+
83
+ ## Root Cause Analysis
84
+ **Layer**: CLI Implementation (Auto-captured)
85
+
86
+ ## Sprint Context
87
+ - **Discovered in**: {data.get('context', {}).get('sprint_id', 'Unknown Sprint')}
88
+ - **Task**: {data.get('context', {}).get('task_id', 'Unknown Task')}
89
+ - **User**: {data.get('user', 'unknown')}
90
+ - **Date**: {now}
91
+
92
+ ## Next Steps
93
+ 1. [ ] Investigate root cause from stack trace
94
+ 2. [ ] Implement fix and validation
95
+ 3. [ ] Close issue via `onecoder issue resolve {issue_id}`
96
+ """
97
+ return template
98
+
99
+ def get_all_issues(self) -> List[Dict[str, Any]]:
100
+ """Parse all markdown issues in the .issues directory."""
101
+ issues_list = []
102
+ for item in self.issues_dir.iterdir():
103
+ if item.is_file() and item.suffix == ".md":
104
+ try:
105
+ content = item.read_text()
106
+ issue_data = self._parse_issue(item.stem, content)
107
+ if issue_data:
108
+ issues_list.append(issue_data)
109
+ except Exception as e:
110
+ print(f"Failed to parse issue {item.name}: {e}")
111
+ return issues_list
112
+
113
+ def _parse_issue(self, filename: str, content: str) -> Optional[Dict[str, Any]]:
114
+ # Filename format: ID-title
115
+ match = re.match(r"^(\d{3})-(.+)$", filename)
116
+ if not match:
117
+ return None
118
+
119
+ issue_id = match.group(1)
120
+ raw_title_slug = match.group(2)
121
+
122
+ # Regex extraction
123
+ title_match = re.search(r"^# Issue: (.+)$", content, re.MULTILINE)
124
+ status_match = re.search(r"## Status\n.*(Open|Resolved|Ignored).*", content, re.MULTILINE | re.IGNORECASE)
125
+ severity_match = re.search(r"## Severity\n\*\*(.+)\*\*", content, re.MULTILINE)
126
+ desc_match = re.search(r"## Description\n(.+?)\n##", content, re.DOTALL)
127
+
128
+ # Metadata extraction (sprint, task)
129
+ sprint_match = re.search(r"- \*\*Discovered in\*\*: (.+)", content)
130
+ task_match = re.search(r"- \*\*Task\*\*: (.+)", content)
131
+
132
+ return {
133
+ "id": issue_id,
134
+ "title": title_match.group(1).strip() if title_match else raw_title_slug,
135
+ "description": desc_match.group(1).strip() if desc_match else "",
136
+ "status": status_match.group(1).lower() if status_match else "open",
137
+ "severity": severity_match.group(1).lower() if severity_match else "medium",
138
+ "sprintId": sprint_match.group(1).strip() if sprint_match else None,
139
+ "metadata": {
140
+ "source": "cli",
141
+ "taskId": task_match.group(1).strip() if task_match else None
142
+ },
143
+ "resolution": {} # Populate if resolved logic added
144
+ }
145
+
146
+ def update_status(self, issue_id: str, status: str, resolution_meta: Optional[Dict[str, Any]] = None) -> bool:
147
+ """Update the status of a specific issue."""
148
+ # Find file
149
+ issue_file = None
150
+ for item in self.issues_dir.iterdir():
151
+ if item.is_file() and item.name.startswith(f"{issue_id}-"):
152
+ issue_file = item
153
+ break
154
+
155
+ if not issue_file:
156
+ return False
157
+
158
+ content = issue_file.read_text()
159
+ now = datetime.now().strftime("%Y-%m-%d")
160
+
161
+ # Update Status Section
162
+ status_line = f"🟢 **{status.title()}** - Resolved on {now}" if status == "resolved" else f"🔴 **{status.title()}**"
163
+ content = re.sub(r"## Status\n.*", f"## Status\n{status_line}", content)
164
+
165
+ # Add/Update Resolution Section if provided
166
+ if resolution_meta and status == "resolved":
167
+ res_md = f"""
168
+ ## Resolution
169
+ - **Resolved By**: {resolution_meta.get('user', 'unknown')}
170
+ - **Date**: {now}
171
+ - **Commit**: `{resolution_meta.get('commit_sha')}`
172
+ - **PR**: {resolution_meta.get('pr_url', 'N/A')}
173
+ - **Fix Task**: {resolution_meta.get('fix_task_id', 'N/A')}
174
+ """
175
+ if "## Resolution" in content:
176
+ # Replace existing
177
+ content = re.sub(r"## Resolution\n.+?(?=\n##|$)", res_md.strip(), content, flags=re.DOTALL)
178
+ else:
179
+ # Append before Next Steps or at end
180
+ if "## Next Steps" in content:
181
+ content = content.replace("## Next Steps", f"{res_md}\n## Next Steps")
182
+ else:
183
+ content += f"\n{res_md}"
184
+
185
+ with open(issue_file, "w") as f:
186
+ f.write(content)
187
+
188
+ return True
@@ -0,0 +1,343 @@
1
+ """Google Jules API Client for OneCoder.
2
+
3
+ This module provides a robust client for interacting with the Google Jules API,
4
+ including session management, activity polling with incremental backoff, and
5
+ PR output detection.
6
+ """
7
+
8
+ import os
9
+ import time
10
+ import requests
11
+ from typing import Dict, Any, List, Optional, Callable
12
+ from dataclasses import dataclass
13
+
14
+
15
+ # Custom Exceptions
16
+ class JulesAPIError(Exception):
17
+ """Base exception for Jules API errors."""
18
+ pass
19
+
20
+
21
+ class JulesAuthError(JulesAPIError):
22
+ """Authentication error (401/403)."""
23
+ pass
24
+
25
+
26
+ class JulesNotFoundError(JulesAPIError):
27
+ """Resource not found (404)."""
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class JulesSession:
33
+ """Represents a Jules session with metadata."""
34
+ id: str
35
+ title: str
36
+ prompt: str
37
+ state: str
38
+ outputs: List[Dict[str, Any]]
39
+ raw_data: Dict[str, Any]
40
+
41
+
42
+ @dataclass
43
+ class JulesActivity:
44
+ """Represents a Jules activity."""
45
+ id: str
46
+ originator: str
47
+ activity_type: str
48
+ data: Dict[str, Any]
49
+
50
+
51
+ class JulesAPIClient:
52
+ """Client for interacting with the Google Jules API.
53
+
54
+ Features:
55
+ - Incremental backoff for activity polling
56
+ - Transient 404 handling with retry logic
57
+ - Session state caching
58
+ - PR output detection
59
+ """
60
+
61
+ # Backoff intervals for polling (in seconds)
62
+ BACKOFF_INTERVALS = [2, 5, 10, 30]
63
+
64
+ def __init__(
65
+ self,
66
+ api_key: Optional[str] = None,
67
+ base_url: str = "https://jules.googleapis.com/v1alpha"
68
+ ):
69
+ """Initialize the Jules API client.
70
+
71
+ Args:
72
+ api_key: Google Jules API key (defaults to JULES_API_KEY env var)
73
+ base_url: API base URL (defaults to production endpoint)
74
+ """
75
+ self.api_key = api_key or os.environ.get("JULES_API_KEY")
76
+ if not self.api_key:
77
+ raise JulesAuthError("JULES_API_KEY not found in environment or parameters")
78
+
79
+ self.base_url = base_url.rstrip("/")
80
+ self._session_cache: Dict[str, JulesSession] = {}
81
+
82
+ def _get_headers(self) -> Dict[str, str]:
83
+ """Get request headers with API key."""
84
+ return {
85
+ "X-Goog-Api-Key": self.api_key,
86
+ "Content-Type": "application/json",
87
+ }
88
+
89
+ def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
90
+ """Handle API response and raise appropriate exceptions.
91
+
92
+ Args:
93
+ response: requests Response object
94
+
95
+ Returns:
96
+ Parsed JSON response
97
+
98
+ Raises:
99
+ JulesAuthError: For 401/403 errors
100
+ JulesNotFoundError: For 404 errors
101
+ JulesAPIError: For other API errors
102
+ """
103
+ if response.status_code == 401 or response.status_code == 403:
104
+ raise JulesAuthError(f"Authentication failed: {response.status_code}")
105
+
106
+ if response.status_code == 404:
107
+ raise JulesNotFoundError(f"Resource not found: {response.url}")
108
+
109
+ if not response.ok:
110
+ raise JulesAPIError(
111
+ f"API request failed: {response.status_code} - {response.text}"
112
+ )
113
+
114
+ return response.json()
115
+
116
+ def create_session(
117
+ self,
118
+ prompt: str,
119
+ source: Optional[str] = None,
120
+ branch: str = "main",
121
+ automation_mode: str = "AUTO_CREATE_PR"
122
+ ) -> JulesSession:
123
+ """Create a new Jules session.
124
+
125
+ Args:
126
+ prompt: The coding task description
127
+ source: GitHub source (e.g., 'sources/github/owner/repo')
128
+ branch: Starting branch (default: 'main')
129
+ automation_mode: Automation mode (default: 'AUTO_CREATE_PR')
130
+
131
+ Returns:
132
+ JulesSession object with session metadata
133
+
134
+ Raises:
135
+ JulesAPIError: If session creation fails
136
+ """
137
+ source = source or os.environ.get("JULES_SOURCE")
138
+ if not source:
139
+ raise JulesAPIError("source parameter or JULES_SOURCE env var required")
140
+
141
+ url = f"{self.base_url}/sessions"
142
+ data = {
143
+ "prompt": prompt,
144
+ "sourceContext": {
145
+ "source": source,
146
+ "githubRepoContext": {
147
+ "startingBranch": branch
148
+ }
149
+ },
150
+ "automationMode": automation_mode,
151
+ "title": f"OneCoder Task: {prompt[:50]}"
152
+ }
153
+
154
+ response = requests.post(
155
+ url,
156
+ headers=self._get_headers(),
157
+ json=data,
158
+ timeout=30
159
+ )
160
+ session_data = self._handle_response(response)
161
+
162
+ session = JulesSession(
163
+ id=session_data.get("id"),
164
+ title=session_data.get("title", ""),
165
+ prompt=session_data.get("prompt", ""),
166
+ state=session_data.get("state", "UNKNOWN"),
167
+ outputs=session_data.get("outputs", []),
168
+ raw_data=session_data
169
+ )
170
+
171
+ # Cache the session
172
+ self._session_cache[session.id] = session
173
+
174
+ return session
175
+
176
+ def get_session(self, session_id: str, use_cache: bool = False) -> JulesSession:
177
+ """Get session metadata.
178
+
179
+ Args:
180
+ session_id: The Jules session ID
181
+ use_cache: If True, return cached session if available
182
+
183
+ Returns:
184
+ JulesSession object
185
+
186
+ Raises:
187
+ JulesNotFoundError: If session not found
188
+ JulesAPIError: For other API errors
189
+ """
190
+ if use_cache and session_id in self._session_cache:
191
+ return self._session_cache[session_id]
192
+
193
+ url = f"{self.base_url}/sessions/{session_id}"
194
+ response = requests.get(url, headers=self._get_headers(), timeout=30)
195
+ session_data = self._handle_response(response)
196
+
197
+ session = JulesSession(
198
+ id=session_data.get("id"),
199
+ title=session_data.get("title", ""),
200
+ prompt=session_data.get("prompt", ""),
201
+ state=session_data.get("state", "UNKNOWN"),
202
+ outputs=session_data.get("outputs", []),
203
+ raw_data=session_data
204
+ )
205
+
206
+ # Update cache
207
+ self._session_cache[session_id] = session
208
+
209
+ return session
210
+
211
+ def list_activities(
212
+ self,
213
+ session_id: str,
214
+ page_size: int = 10,
215
+ retry_on_404: bool = True,
216
+ max_retries: int = 3
217
+ ) -> List[JulesActivity]:
218
+ """List activities for a session with retry logic.
219
+
220
+ Args:
221
+ session_id: The Jules session ID
222
+ page_size: Number of activities to fetch
223
+ retry_on_404: If True, retry on transient 404 errors
224
+ max_retries: Maximum number of retries for 404
225
+
226
+ Returns:
227
+ List of JulesActivity objects
228
+
229
+ Raises:
230
+ JulesNotFoundError: If session not found after retries
231
+ JulesAPIError: For other API errors
232
+ """
233
+ url = f"{self.base_url}/sessions/{session_id}/activities"
234
+ params = {"pageSize": page_size}
235
+
236
+ for attempt in range(max_retries):
237
+ try:
238
+ response = requests.get(
239
+ url,
240
+ headers=self._get_headers(),
241
+ params=params,
242
+ timeout=30
243
+ )
244
+ data = self._handle_response(response)
245
+
246
+ activities = []
247
+ for activity_data in data.get("activities", []):
248
+ # Determine activity type
249
+ activity_type = "unknown"
250
+ if "planGenerated" in activity_data:
251
+ activity_type = "plan_generated"
252
+ elif "progressUpdated" in activity_data:
253
+ activity_type = "progress_updated"
254
+ elif "sessionCompleted" in activity_data:
255
+ activity_type = "session_completed"
256
+
257
+ activities.append(JulesActivity(
258
+ id=activity_data.get("id", ""),
259
+ originator=activity_data.get("originator", "unknown"),
260
+ activity_type=activity_type,
261
+ data=activity_data
262
+ ))
263
+
264
+ return activities
265
+
266
+ except JulesNotFoundError:
267
+ if not retry_on_404 or attempt >= max_retries - 1:
268
+ raise
269
+ # Transient 404 - wait before retry
270
+ time.sleep(2 ** attempt) # Exponential backoff: 1s, 2s, 4s
271
+
272
+ return []
273
+
274
+ def poll_until_complete(
275
+ self,
276
+ session_id: str,
277
+ callback: Optional[Callable[[JulesSession, List[JulesActivity]], None]] = None,
278
+ max_iterations: int = 60
279
+ ) -> JulesSession:
280
+ """Poll session until completion with incremental backoff.
281
+
282
+ Args:
283
+ session_id: The Jules session ID
284
+ callback: Optional callback function called on each poll
285
+ max_iterations: Maximum number of polling iterations
286
+
287
+ Returns:
288
+ Final JulesSession object
289
+
290
+ Raises:
291
+ JulesAPIError: If polling fails
292
+ """
293
+ backoff_index = 0
294
+
295
+ for iteration in range(max_iterations):
296
+ # Get current session state
297
+ session = self.get_session(session_id)
298
+
299
+ # Get recent activities
300
+ try:
301
+ activities = self.list_activities(session_id, page_size=5)
302
+ except JulesNotFoundError:
303
+ # Activities might not be available yet
304
+ activities = []
305
+
306
+ # Call callback if provided
307
+ if callback:
308
+ callback(session, activities)
309
+
310
+ # Check if session is complete
311
+ if session.state in ["COMPLETED", "FAILED", "CANCELLED"]:
312
+ return session
313
+
314
+ # Incremental backoff
315
+ wait_time = self.BACKOFF_INTERVALS[
316
+ min(backoff_index, len(self.BACKOFF_INTERVALS) - 1)
317
+ ]
318
+ time.sleep(wait_time)
319
+ backoff_index += 1
320
+
321
+ # Max iterations reached
322
+ return session
323
+
324
+ def detect_pr_output(self, session_id: str) -> Optional[Dict[str, str]]:
325
+ """Detect PR output from a session.
326
+
327
+ Args:
328
+ session_id: The Jules session ID
329
+
330
+ Returns:
331
+ Dict with 'url' and 'title' if PR found, None otherwise
332
+ """
333
+ session = self.get_session(session_id)
334
+
335
+ for output in session.outputs:
336
+ if "pullRequest" in output:
337
+ pr = output["pullRequest"]
338
+ return {
339
+ "url": pr.get("url", ""),
340
+ "title": pr.get("title", "")
341
+ }
342
+
343
+ return None
onecoder/knowledge.py ADDED
@@ -0,0 +1,106 @@
1
+ import os
2
+ import json
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Dict, Any, Optional
6
+
7
+ class ProjectKnowledge:
8
+ """
9
+ Manages L2 (Project Context) by aggregating durable awareness files.
10
+ """
11
+ def __init__(self, directory: str = "."):
12
+ self.directory = self._find_repo_root(Path(directory).absolute())
13
+ self.agents_md = self.directory / "AGENTS.md"
14
+ self.antigravity_md = self.directory / "ANTIGRAVITY.md"
15
+
16
+ def _find_repo_root(self, start_path: Path) -> Path:
17
+ """Traverses upwards to find the repository root."""
18
+ curr = start_path
19
+ while curr != curr.parent:
20
+ if (curr / ".sprint").exists() or (curr / ".git").exists():
21
+ return curr
22
+ curr = curr.parent
23
+ return start_path # Fallback to start_path
24
+
25
+ def get_durable_context(self) -> Dict[str, str]:
26
+ """Reads and returns the content of durable awareness files."""
27
+ context = {}
28
+ if self.agents_md.exists():
29
+ context["agents_guidelines"] = self.agents_md.read_text()
30
+ if self.antigravity_md.exists():
31
+ context["antigravity_awareness"] = self.antigravity_md.read_text()
32
+ return context
33
+
34
+ def get_l1_context(self) -> Optional[Dict[str, Any]]:
35
+ """
36
+ Attempts to fetch L1 context from ai_sprint SDK or sprint-cli fallback.
37
+ """
38
+ sprint_id = os.environ.get("ACTIVE_SPRINT_ID")
39
+ if not sprint_id:
40
+ return None
41
+
42
+ # 1. Try SDK-first approach
43
+ try:
44
+ from ai_sprint.state import SprintStateManager
45
+ sprint_dir = self.directory / ".sprint" / sprint_id
46
+ if sprint_dir.exists():
47
+ state_manager = SprintStateManager(sprint_dir)
48
+ return state_manager.get_context_summary()
49
+ except (ImportError, Exception):
50
+ pass
51
+
52
+ # 2. Fallback to CLI
53
+ try:
54
+ # We assume 'sprint' is available in the environment or path
55
+ # Using ~/.local/bin/uv run sprint as a safer fallback based on ANTIGRAVITY.md quirks
56
+ uv_path = Path.home() / ".local" / "bin" / "uv"
57
+ if uv_path.exists():
58
+ cmd = [str(uv_path), "run", "sprint", "context", "--json"]
59
+ else:
60
+ cmd = ["sprint", "context", "--json"]
61
+
62
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
63
+ if result.returncode == 0:
64
+ return json.loads(result.stdout)
65
+ except Exception:
66
+ pass
67
+ return None
68
+
69
+ def aggregate_knowledge(self) -> Dict[str, Any]:
70
+ """
71
+ Aggregates L2 durable context and L1 ephemeral context (if available).
72
+ """
73
+ knowledge = {
74
+ "project_root": str(self.directory),
75
+ "durable_context": self.get_durable_context(),
76
+ "ephemeral_context": self.get_l1_context()
77
+ }
78
+ return knowledge
79
+
80
+ def get_rag_ready_output(self) -> str:
81
+ """Returns a string representation suitable for agent RAG ingestion."""
82
+ data = self.aggregate_knowledge()
83
+ output = [f"# Project Knowledge: {self.directory.name}\n"]
84
+
85
+ if data["durable_context"]:
86
+ output.append("## L2: Project Context (Durable)")
87
+ for key, content in data["durable_context"].items():
88
+ output.append(f"### {key.replace('_', ' ').title()}")
89
+ output.append(content)
90
+ output.append("")
91
+
92
+ if data["ephemeral_context"] and "error" not in data["ephemeral_context"]:
93
+ output.append("## L1: Task Context (Ephemeral)")
94
+ ctx = data["ephemeral_context"]
95
+ output.append(f"**Sprint**: {ctx.get('sprint_id', 'Unknown')}")
96
+ output.append(f"**Goal**: {ctx.get('goal', 'N/A')}")
97
+ if ctx.get('active_task'):
98
+ t = ctx['active_task']
99
+ output.append(f"**Active Task**: [{t.get('id')}] {t.get('title')}")
100
+
101
+ if ctx.get('todos'):
102
+ output.append("\n**Active TODOs**:")
103
+ for todo in ctx['todos']:
104
+ output.append(f" {todo}")
105
+
106
+ return "\n".join(output)
onecoder/llm.py ADDED
@@ -0,0 +1,61 @@
1
+ import os
2
+ import json
3
+ from typing import Optional, Dict, Any, List
4
+
5
+ class LLMClient:
6
+ """
7
+ A unified client for Large Language Model interactions.
8
+ Wraps litellm or provides fallback/mocking.
9
+ """
10
+ def __init__(self, model_name: str = "openrouter/xiaomi/mimo-v2-flash:free"):
11
+ self.model_name = model_name
12
+ self.api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("GEMINI_API_KEY")
13
+ # Fallback to mock if test env or no key
14
+ self.is_mock = not self.api_key
15
+
16
+ def generate_json(self, prompt: str, system_prompt: str = "") -> Dict[str, Any]:
17
+ """Generate a JSON response from the LLM."""
18
+ if self.is_mock:
19
+ return self._mock_response(prompt)
20
+
21
+ try:
22
+ import litellm
23
+ messages = []
24
+ if system_prompt:
25
+ messages.append({"role": "system", "content": system_prompt})
26
+ messages.append({"role": "user", "content": prompt})
27
+
28
+ response = litellm.completion(
29
+ model=self.model_name,
30
+ messages=messages,
31
+ api_key=self.api_key,
32
+ response_format={ "type": "json_object" }
33
+ )
34
+
35
+ content = response.choices[0].message.content
36
+ return json.loads(content)
37
+ except Exception as e:
38
+ # Fallback to mock on error to allow flow to continue (with warning)
39
+ print(f"LLM Error: {e}. Falling back to mock.")
40
+ return self._mock_response(prompt)
41
+
42
+ def _mock_response(self, prompt: str) -> Dict[str, Any]:
43
+ """Return a mock based on the prompt content (heuristic)."""
44
+ # Heuristic for "Review": if prompt mentions violations or policy, return pass/fail
45
+ if "governance.yaml" in prompt:
46
+ # Check for L1 failures in the injected verdict
47
+ if '"errors": 0' not in prompt or '"lint_violations": 0' not in prompt:
48
+ # If there are errors in the injected verdict, mock a failure
49
+ return {
50
+ "pass": False,
51
+ "violations": ["L1 Verification Failure detected in the deterministic tier."],
52
+ "feedback": "FAILED (Mock Review). Deterministic L1 checks failed. Please remediate the build/lint errors identified in THE VERDICT.",
53
+ "mitigation_notes": "The L1 verdict indicates build/lint failures. Use 'kit' tools to identify the specific lines in App.tsx and fix the unused variable issue."
54
+ }
55
+
56
+ return {
57
+ "pass": True,
58
+ "violations": [],
59
+ "feedback": "LGTM (Mock Review). Policy compliance verified."
60
+ }
61
+ return {"response": "Mock response"}