devloop 0.2.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 (55) hide show
  1. devloop/__init__.py +3 -0
  2. devloop/agents/__init__.py +33 -0
  3. devloop/agents/agent_health_monitor.py +105 -0
  4. devloop/agents/ci_monitor.py +237 -0
  5. devloop/agents/code_rabbit.py +248 -0
  6. devloop/agents/doc_lifecycle.py +374 -0
  7. devloop/agents/echo.py +24 -0
  8. devloop/agents/file_logger.py +46 -0
  9. devloop/agents/formatter.py +511 -0
  10. devloop/agents/git_commit_assistant.py +421 -0
  11. devloop/agents/linter.py +399 -0
  12. devloop/agents/performance_profiler.py +284 -0
  13. devloop/agents/security_scanner.py +322 -0
  14. devloop/agents/snyk.py +292 -0
  15. devloop/agents/test_runner.py +484 -0
  16. devloop/agents/type_checker.py +242 -0
  17. devloop/cli/__init__.py +1 -0
  18. devloop/cli/commands/__init__.py +1 -0
  19. devloop/cli/commands/custom_agents.py +144 -0
  20. devloop/cli/commands/feedback.py +161 -0
  21. devloop/cli/commands/summary.py +50 -0
  22. devloop/cli/main.py +430 -0
  23. devloop/cli/main_v1.py +144 -0
  24. devloop/collectors/__init__.py +17 -0
  25. devloop/collectors/base.py +55 -0
  26. devloop/collectors/filesystem.py +126 -0
  27. devloop/collectors/git.py +171 -0
  28. devloop/collectors/manager.py +159 -0
  29. devloop/collectors/process.py +221 -0
  30. devloop/collectors/system.py +195 -0
  31. devloop/core/__init__.py +21 -0
  32. devloop/core/agent.py +206 -0
  33. devloop/core/agent_template.py +498 -0
  34. devloop/core/amp_integration.py +166 -0
  35. devloop/core/auto_fix.py +224 -0
  36. devloop/core/config.py +272 -0
  37. devloop/core/context.py +0 -0
  38. devloop/core/context_store.py +530 -0
  39. devloop/core/contextual_feedback.py +311 -0
  40. devloop/core/custom_agent.py +439 -0
  41. devloop/core/debug_trace.py +289 -0
  42. devloop/core/event.py +105 -0
  43. devloop/core/event_store.py +316 -0
  44. devloop/core/feedback.py +311 -0
  45. devloop/core/learning.py +351 -0
  46. devloop/core/manager.py +219 -0
  47. devloop/core/performance.py +433 -0
  48. devloop/core/proactive_feedback.py +302 -0
  49. devloop/core/summary_formatter.py +159 -0
  50. devloop/core/summary_generator.py +275 -0
  51. devloop-0.2.0.dist-info/METADATA +705 -0
  52. devloop-0.2.0.dist-info/RECORD +55 -0
  53. devloop-0.2.0.dist-info/WHEEL +4 -0
  54. devloop-0.2.0.dist-info/entry_points.txt +3 -0
  55. devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,511 @@
1
+ """Formatter agent - auto-formats code on save."""
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from devloop.core.agent import Agent, AgentResult
9
+ from devloop.core.context_store import Finding, Severity
10
+ from devloop.core.event import Event
11
+
12
+
13
+ class FormatterConfig:
14
+ """Configuration for FormatterAgent."""
15
+
16
+ def __init__(self, config: Dict[str, Any]):
17
+ self.enabled = config.get("enabled", True)
18
+ self.format_on_save = config.get("formatOnSave", True)
19
+ self.report_only = config.get("reportOnly", False)
20
+ self.file_patterns = config.get(
21
+ "filePatterns", ["**/*.py", "**/*.js", "**/*.ts"]
22
+ )
23
+ self.formatters = config.get(
24
+ "formatters",
25
+ {
26
+ "python": "black",
27
+ "javascript": "prettier",
28
+ "typescript": "prettier",
29
+ "json": "prettier",
30
+ "markdown": "prettier",
31
+ },
32
+ )
33
+
34
+
35
+ class FormatterAgent(Agent):
36
+ """Agent that auto-formats code files."""
37
+
38
+ def __init__(
39
+ self,
40
+ name: str,
41
+ triggers: List[str],
42
+ event_bus,
43
+ config: Dict[str, Any] | None = None,
44
+ ):
45
+ super().__init__(name, triggers, event_bus)
46
+ self.config = FormatterConfig(config or {})
47
+
48
+ # Loop prevention mechanisms
49
+ self._recent_formats: Dict[
50
+ str, List[float]
51
+ ] = {} # file_path -> list of timestamps
52
+ self._format_timeout = 30 # seconds
53
+ self._loop_detection_window = 10 # seconds
54
+ self._max_consecutive_formats = 3 # per file per window
55
+
56
+ async def handle(self, event: Event) -> AgentResult:
57
+ """Handle file change event by formatting the file."""
58
+ # Skip if both format_on_save and report_only are disabled
59
+ if not self.config.format_on_save and not self.config.report_only:
60
+ return AgentResult(
61
+ agent_name=self.name,
62
+ success=True,
63
+ duration=0,
64
+ message="Formatter disabled (not in format-on-save or report-only mode)",
65
+ )
66
+
67
+ # Extract file path
68
+ file_path = event.payload.get("path")
69
+ if not file_path:
70
+ return AgentResult(
71
+ agent_name=self.name,
72
+ success=True,
73
+ duration=0,
74
+ message="No file path in event",
75
+ )
76
+
77
+ path = Path(file_path)
78
+
79
+ # Loop prevention: Check for formatting loops
80
+ if self._detect_formatting_loop(path):
81
+ await self._write_finding_to_context(
82
+ path=path,
83
+ formatter="loop_detector",
84
+ severity="warning",
85
+ message=f"Prevented formatting loop for {path.name} (too many recent format operations)",
86
+ blocking=True,
87
+ )
88
+ result = AgentResult(
89
+ agent_name=self.name,
90
+ success=False,
91
+ duration=0,
92
+ message=f"Prevented formatting loop for {path.name} (too many recent format operations)",
93
+ error="FORMATTING_LOOP_DETECTED",
94
+ )
95
+ return result
96
+
97
+ # Check if file should be formatted
98
+ if not self._should_format(path):
99
+ result = AgentResult(
100
+ agent_name=self.name,
101
+ success=True,
102
+ duration=0,
103
+ message=f"Skipped {path.name} (not in patterns)",
104
+ )
105
+ return result
106
+
107
+ # Get appropriate formatter
108
+ formatter = self._get_formatter_for_file(path)
109
+ if not formatter:
110
+ result = AgentResult(
111
+ agent_name=self.name,
112
+ success=True,
113
+ duration=0,
114
+ message=f"No formatter configured for {path.suffix}",
115
+ )
116
+ return result
117
+
118
+ # Idempotency check: Only format if file actually needs formatting
119
+ if self.config.format_on_save and not self.config.report_only:
120
+ needs_formatting, check_error = await self._check_formatter(formatter, path)
121
+ if check_error:
122
+ await self._write_finding_to_context(
123
+ path=path,
124
+ formatter=formatter,
125
+ severity="error",
126
+ message=f"Failed to check if {path.name} needs formatting: {check_error}",
127
+ blocking=True,
128
+ )
129
+ result = AgentResult(
130
+ agent_name=self.name,
131
+ success=False,
132
+ duration=0,
133
+ message=f"Failed to check if {path.name} needs formatting: {check_error}",
134
+ error=check_error,
135
+ )
136
+ return result
137
+ if not needs_formatting:
138
+ result = AgentResult(
139
+ agent_name=self.name,
140
+ success=True,
141
+ duration=0,
142
+ message=f"{path.name} is already formatted",
143
+ )
144
+ return result
145
+
146
+ # Run formatter
147
+ if self.config.report_only:
148
+ # Check mode: see if formatting is needed but don't modify
149
+ needs_formatting, error = await self._check_formatter(formatter, path)
150
+ if error:
151
+ message = f"Check failed for {path.name}: {error}"
152
+ success = False
153
+ await self._write_finding_to_context(
154
+ path=path,
155
+ formatter=formatter,
156
+ severity="error",
157
+ message=message,
158
+ blocking=True,
159
+ )
160
+ elif needs_formatting:
161
+ message = (
162
+ f"Would format {path.name} with {formatter} (report-only mode)"
163
+ )
164
+ success = True
165
+ await self._write_finding_to_context(
166
+ path=path,
167
+ formatter=formatter,
168
+ severity="info",
169
+ message=f"{path.name} needs formatting with {formatter}",
170
+ auto_fixable=True,
171
+ )
172
+ else:
173
+ message = f"No formatting needed for {path.name}"
174
+ success = True
175
+
176
+ result = AgentResult(
177
+ agent_name=self.name,
178
+ success=success,
179
+ duration=0,
180
+ message=message,
181
+ data={
182
+ "file": str(path),
183
+ "formatter": formatter,
184
+ "needs_formatting": needs_formatting,
185
+ "report_only": True,
186
+ },
187
+ error=error,
188
+ )
189
+ return result
190
+ else:
191
+ # Format mode: actually modify the file
192
+ success, error = await self._run_formatter(formatter, path)
193
+
194
+ if success:
195
+ # Record successful formatting operation for loop prevention
196
+ self._record_formatting_operation(path)
197
+ message = f"Formatted {path.name} with {formatter}"
198
+ else:
199
+ message = f"Failed to format {path.name}: {error}"
200
+ await self._write_finding_to_context(
201
+ path=path,
202
+ formatter=formatter,
203
+ severity="error",
204
+ message=message,
205
+ blocking=True,
206
+ )
207
+
208
+ result = AgentResult(
209
+ agent_name=self.name,
210
+ success=success,
211
+ duration=0,
212
+ message=message,
213
+ data={"file": str(path), "formatter": formatter, "formatted": success},
214
+ error=error if not success else None,
215
+ )
216
+ return result
217
+
218
+ def _should_format(self, path: Path) -> bool:
219
+ """Check if file should be formatted based on patterns."""
220
+ if not path.exists():
221
+ return False
222
+
223
+ suffix = path.suffix
224
+ for pattern in self.config.file_patterns:
225
+ if pattern.endswith(suffix):
226
+ return True
227
+ if "*" in pattern and suffix in pattern:
228
+ return True
229
+
230
+ return False
231
+
232
+ def _get_formatter_for_file(self, path: Path) -> Optional[str]:
233
+ """Get the appropriate formatter for a file."""
234
+ suffix = path.suffix.lstrip(".")
235
+
236
+ # Map file extensions to language
237
+ extension_map = {
238
+ "py": "python",
239
+ "js": "javascript",
240
+ "jsx": "javascript",
241
+ "ts": "typescript",
242
+ "tsx": "typescript",
243
+ "json": "json",
244
+ "md": "markdown",
245
+ }
246
+
247
+ language = extension_map.get(suffix)
248
+ if language:
249
+ return self.config.formatters.get(language)
250
+
251
+ return None
252
+
253
+ def _detect_formatting_loop(self, path: Path) -> bool:
254
+ """Detect if we're in a formatting loop for this file."""
255
+ import time
256
+
257
+ file_key = str(path.resolve())
258
+ now = time.time()
259
+
260
+ # Clean up old entries (older than detection window)
261
+ for k in list(self._recent_formats.keys()):
262
+ self._recent_formats[k] = [
263
+ ts
264
+ for ts in self._recent_formats[k]
265
+ if now - ts < self._loop_detection_window
266
+ ]
267
+ if not self._recent_formats[k]:
268
+ del self._recent_formats[k]
269
+
270
+ # Count recent formats for this file
271
+ timestamps = self._recent_formats.get(file_key, [])
272
+ recent_count = len(timestamps)
273
+
274
+ if recent_count >= self._max_consecutive_formats + 1:
275
+ self.logger.warning(
276
+ f"Formatting loop detected for {path.name}: "
277
+ f"{recent_count} formats in {self._loop_detection_window}s"
278
+ )
279
+ return True
280
+
281
+ return False
282
+
283
+ def _record_formatting_operation(self, path: Path) -> None:
284
+ """Record that we just formatted this file."""
285
+ import time
286
+
287
+ file_key = str(path.resolve())
288
+ if file_key not in self._recent_formats:
289
+ self._recent_formats[file_key] = []
290
+ self._recent_formats[file_key].append(time.time())
291
+
292
+ async def _write_finding_to_context(
293
+ self,
294
+ path: Path,
295
+ formatter: str,
296
+ severity: str,
297
+ message: str,
298
+ blocking: bool = False,
299
+ auto_fixable: bool = False,
300
+ ) -> None:
301
+ """Write a formatting finding to the context store."""
302
+ from devloop.core.context_store import context_store
303
+
304
+ finding = Finding(
305
+ id=f"{self.name}-{path}-{formatter}",
306
+ agent=self.name,
307
+ timestamp=str(datetime.now()),
308
+ file=str(path),
309
+ severity=Severity(severity),
310
+ message=message,
311
+ suggestion=f"Run {formatter} on {path}" if auto_fixable else "",
312
+ auto_fixable=auto_fixable,
313
+ context={
314
+ "formatter": formatter,
315
+ "blocking": blocking,
316
+ },
317
+ )
318
+ await context_store.add_finding(finding)
319
+
320
+ async def _run_formatter(
321
+ self, formatter: str, path: Path
322
+ ) -> tuple[bool, Optional[str]]:
323
+ """Run formatter on a file with timeout protection."""
324
+ try:
325
+ # Add timeout protection to prevent hanging formatters
326
+ import asyncio
327
+
328
+ if formatter == "black":
329
+ result = await asyncio.wait_for(
330
+ self._run_black(path), timeout=self._format_timeout
331
+ )
332
+ return result
333
+ elif formatter == "prettier":
334
+ result = await asyncio.wait_for(
335
+ self._run_prettier(path), timeout=self._format_timeout
336
+ )
337
+ return result
338
+ else:
339
+ return False, f"Unknown formatter: {formatter}"
340
+
341
+ except asyncio.TimeoutError:
342
+ error_msg = f"Formatter {formatter} timed out after {self._format_timeout}s on {path.name}"
343
+ self.logger.error(error_msg)
344
+ return False, error_msg
345
+ except Exception as e:
346
+ self.logger.error(f"Error running {formatter}: {e}")
347
+ return False, str(e)
348
+
349
+ async def _check_formatter(
350
+ self, formatter: str, path: Path
351
+ ) -> tuple[bool, Optional[str]]:
352
+ """Check if file needs formatting without modifying it."""
353
+ try:
354
+ if formatter == "black":
355
+ return await self._check_black(path)
356
+ elif formatter == "prettier":
357
+ return await self._check_prettier(path)
358
+ else:
359
+ return False, f"Unknown formatter: {formatter}"
360
+
361
+ except Exception as e:
362
+ self.logger.error(f"Error checking {formatter}: {e}")
363
+ return False, str(e)
364
+
365
+ async def _run_black(self, path: Path) -> tuple[bool, Optional[str]]:
366
+ """Run black formatter on Python file."""
367
+ try:
368
+ # Get updated environment with venv bin in PATH
369
+ import os
370
+
371
+ env = os.environ.copy()
372
+ venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
373
+ if venv_bin.exists():
374
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
375
+
376
+ # Check if black is installed
377
+ check = await asyncio.create_subprocess_exec(
378
+ "black",
379
+ "--version",
380
+ stdout=asyncio.subprocess.PIPE,
381
+ stderr=asyncio.subprocess.PIPE,
382
+ env=env,
383
+ )
384
+ await check.communicate()
385
+
386
+ if check.returncode != 0:
387
+ return False, "black not installed"
388
+
389
+ # Run black
390
+ proc = await asyncio.create_subprocess_exec(
391
+ "black",
392
+ "--quiet",
393
+ str(path),
394
+ stdout=asyncio.subprocess.PIPE,
395
+ stderr=asyncio.subprocess.PIPE,
396
+ env=env,
397
+ )
398
+
399
+ stdout, stderr = await proc.communicate()
400
+
401
+ if proc.returncode == 0:
402
+ return True, None
403
+ else:
404
+ error = stderr.decode() if stderr else "Unknown error"
405
+ return False, error
406
+
407
+ except FileNotFoundError:
408
+ return False, "black command not found"
409
+
410
+ async def _run_prettier(self, path: Path) -> tuple[bool, Optional[str]]:
411
+ """Run prettier formatter on JavaScript/TypeScript/JSON/Markdown file."""
412
+ try:
413
+ # Check if prettier is installed
414
+ check = await asyncio.create_subprocess_exec(
415
+ "prettier",
416
+ "--version",
417
+ stdout=asyncio.subprocess.PIPE,
418
+ stderr=asyncio.subprocess.PIPE,
419
+ )
420
+ await check.communicate()
421
+
422
+ if check.returncode != 0:
423
+ return False, "prettier not installed"
424
+
425
+ # Run prettier
426
+ proc = await asyncio.create_subprocess_exec(
427
+ "prettier",
428
+ "--write",
429
+ str(path),
430
+ stdout=asyncio.subprocess.PIPE,
431
+ stderr=asyncio.subprocess.PIPE,
432
+ )
433
+
434
+ stdout, stderr = await proc.communicate()
435
+
436
+ if proc.returncode == 0:
437
+ return True, None
438
+ else:
439
+ error = stderr.decode() if stderr else "Unknown error"
440
+ return False, error
441
+
442
+ except FileNotFoundError:
443
+ return False, "prettier command not found"
444
+
445
+ async def _check_black(self, path: Path) -> tuple[bool, Optional[str]]:
446
+ """Check if black would format this file (without modifying it)."""
447
+ try:
448
+ # Get updated environment with venv bin in PATH
449
+ import os
450
+
451
+ env = os.environ.copy()
452
+ venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
453
+ if venv_bin.exists():
454
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
455
+
456
+ # Run black in check mode
457
+ proc = await asyncio.create_subprocess_exec(
458
+ "black",
459
+ "--check",
460
+ "--quiet",
461
+ str(path),
462
+ stdout=asyncio.subprocess.PIPE,
463
+ stderr=asyncio.subprocess.PIPE,
464
+ env=env,
465
+ )
466
+
467
+ stdout, stderr = await proc.communicate()
468
+
469
+ # black --check returns 0 if file is formatted, 1 if would reformat
470
+ if proc.returncode == 0:
471
+ # File is already formatted
472
+ return False, None
473
+ elif proc.returncode == 1:
474
+ # File would be reformatted
475
+ return True, None
476
+ else:
477
+ # Error occurred
478
+ error = stderr.decode() if stderr else "Unknown error"
479
+ return False, error
480
+
481
+ except FileNotFoundError:
482
+ return False, "black command not found"
483
+
484
+ async def _check_prettier(self, path: Path) -> tuple[bool, Optional[str]]:
485
+ """Check if prettier would format this file (without modifying it)."""
486
+ try:
487
+ # Run prettier in check mode
488
+ proc = await asyncio.create_subprocess_exec(
489
+ "prettier",
490
+ "--check",
491
+ str(path),
492
+ stdout=asyncio.subprocess.PIPE,
493
+ stderr=asyncio.subprocess.PIPE,
494
+ )
495
+
496
+ stdout, stderr = await proc.communicate()
497
+
498
+ # prettier --check returns 0 if formatted, 1 if would reformat
499
+ if proc.returncode == 0:
500
+ # File is already formatted
501
+ return False, None
502
+ elif proc.returncode == 1:
503
+ # File would be reformatted
504
+ return True, None
505
+ else:
506
+ # Error occurred
507
+ error = stderr.decode() if stderr else "Unknown error"
508
+ return False, error
509
+
510
+ except FileNotFoundError:
511
+ return False, "prettier command not found"