agentic-devtools 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 (92) hide show
  1. agdt_ai_helpers/__init__.py +34 -0
  2. agentic_devtools/__init__.py +8 -0
  3. agentic_devtools/background_tasks.py +598 -0
  4. agentic_devtools/cli/__init__.py +1 -0
  5. agentic_devtools/cli/azure_devops/__init__.py +222 -0
  6. agentic_devtools/cli/azure_devops/async_commands.py +1218 -0
  7. agentic_devtools/cli/azure_devops/auth.py +34 -0
  8. agentic_devtools/cli/azure_devops/commands.py +728 -0
  9. agentic_devtools/cli/azure_devops/config.py +49 -0
  10. agentic_devtools/cli/azure_devops/file_review_commands.py +1038 -0
  11. agentic_devtools/cli/azure_devops/helpers.py +561 -0
  12. agentic_devtools/cli/azure_devops/mark_reviewed.py +756 -0
  13. agentic_devtools/cli/azure_devops/pipeline_commands.py +724 -0
  14. agentic_devtools/cli/azure_devops/pr_summary_commands.py +579 -0
  15. agentic_devtools/cli/azure_devops/pull_request_details_commands.py +596 -0
  16. agentic_devtools/cli/azure_devops/review_commands.py +700 -0
  17. agentic_devtools/cli/azure_devops/review_helpers.py +191 -0
  18. agentic_devtools/cli/azure_devops/review_jira.py +308 -0
  19. agentic_devtools/cli/azure_devops/review_prompts.py +263 -0
  20. agentic_devtools/cli/azure_devops/run_details_commands.py +935 -0
  21. agentic_devtools/cli/azure_devops/vpn_toggle.py +1220 -0
  22. agentic_devtools/cli/git/__init__.py +91 -0
  23. agentic_devtools/cli/git/async_commands.py +294 -0
  24. agentic_devtools/cli/git/commands.py +399 -0
  25. agentic_devtools/cli/git/core.py +152 -0
  26. agentic_devtools/cli/git/diff.py +210 -0
  27. agentic_devtools/cli/git/operations.py +737 -0
  28. agentic_devtools/cli/jira/__init__.py +114 -0
  29. agentic_devtools/cli/jira/adf.py +105 -0
  30. agentic_devtools/cli/jira/async_commands.py +439 -0
  31. agentic_devtools/cli/jira/async_status.py +27 -0
  32. agentic_devtools/cli/jira/commands.py +28 -0
  33. agentic_devtools/cli/jira/comment_commands.py +141 -0
  34. agentic_devtools/cli/jira/config.py +69 -0
  35. agentic_devtools/cli/jira/create_commands.py +293 -0
  36. agentic_devtools/cli/jira/formatting.py +131 -0
  37. agentic_devtools/cli/jira/get_commands.py +287 -0
  38. agentic_devtools/cli/jira/helpers.py +278 -0
  39. agentic_devtools/cli/jira/parse_error_report.py +352 -0
  40. agentic_devtools/cli/jira/role_commands.py +560 -0
  41. agentic_devtools/cli/jira/state_helpers.py +39 -0
  42. agentic_devtools/cli/jira/update_commands.py +222 -0
  43. agentic_devtools/cli/jira/vpn_wrapper.py +58 -0
  44. agentic_devtools/cli/release/__init__.py +5 -0
  45. agentic_devtools/cli/release/commands.py +113 -0
  46. agentic_devtools/cli/release/helpers.py +113 -0
  47. agentic_devtools/cli/runner.py +318 -0
  48. agentic_devtools/cli/state.py +174 -0
  49. agentic_devtools/cli/subprocess_utils.py +109 -0
  50. agentic_devtools/cli/tasks/__init__.py +28 -0
  51. agentic_devtools/cli/tasks/commands.py +851 -0
  52. agentic_devtools/cli/testing.py +442 -0
  53. agentic_devtools/cli/workflows/__init__.py +80 -0
  54. agentic_devtools/cli/workflows/advancement.py +204 -0
  55. agentic_devtools/cli/workflows/base.py +240 -0
  56. agentic_devtools/cli/workflows/checklist.py +278 -0
  57. agentic_devtools/cli/workflows/commands.py +1610 -0
  58. agentic_devtools/cli/workflows/manager.py +802 -0
  59. agentic_devtools/cli/workflows/preflight.py +323 -0
  60. agentic_devtools/cli/workflows/worktree_setup.py +1110 -0
  61. agentic_devtools/dispatcher.py +704 -0
  62. agentic_devtools/file_locking.py +203 -0
  63. agentic_devtools/prompts/__init__.py +38 -0
  64. agentic_devtools/prompts/apply-pull-request-review-suggestions/default-initiate-prompt.md +82 -0
  65. agentic_devtools/prompts/create-jira-epic/default-initiate-prompt.md +63 -0
  66. agentic_devtools/prompts/create-jira-issue/default-initiate-prompt.md +306 -0
  67. agentic_devtools/prompts/create-jira-subtask/default-initiate-prompt.md +57 -0
  68. agentic_devtools/prompts/loader.py +377 -0
  69. agentic_devtools/prompts/pull-request-review/default-completion-prompt.md +45 -0
  70. agentic_devtools/prompts/pull-request-review/default-decision-prompt.md +63 -0
  71. agentic_devtools/prompts/pull-request-review/default-file-review-prompt.md +69 -0
  72. agentic_devtools/prompts/pull-request-review/default-initiate-prompt.md +50 -0
  73. agentic_devtools/prompts/pull-request-review/default-summary-prompt.md +40 -0
  74. agentic_devtools/prompts/update-jira-issue/default-initiate-prompt.md +78 -0
  75. agentic_devtools/prompts/work-on-jira-issue/default-checklist-creation-prompt.md +58 -0
  76. agentic_devtools/prompts/work-on-jira-issue/default-commit-prompt.md +47 -0
  77. agentic_devtools/prompts/work-on-jira-issue/default-completion-prompt.md +65 -0
  78. agentic_devtools/prompts/work-on-jira-issue/default-implementation-prompt.md +66 -0
  79. agentic_devtools/prompts/work-on-jira-issue/default-implementation-review-prompt.md +60 -0
  80. agentic_devtools/prompts/work-on-jira-issue/default-initiate-prompt.md +67 -0
  81. agentic_devtools/prompts/work-on-jira-issue/default-planning-prompt.md +50 -0
  82. agentic_devtools/prompts/work-on-jira-issue/default-pull-request-prompt.md +56 -0
  83. agentic_devtools/prompts/work-on-jira-issue/default-retrieve-prompt.md +29 -0
  84. agentic_devtools/prompts/work-on-jira-issue/default-setup-prompt.md +19 -0
  85. agentic_devtools/prompts/work-on-jira-issue/default-verification-prompt.md +73 -0
  86. agentic_devtools/state.py +754 -0
  87. agentic_devtools/task_state.py +902 -0
  88. agentic_devtools-0.2.0.dist-info/METADATA +544 -0
  89. agentic_devtools-0.2.0.dist-info/RECORD +92 -0
  90. agentic_devtools-0.2.0.dist-info/WHEEL +4 -0
  91. agentic_devtools-0.2.0.dist-info/entry_points.txt +79 -0
  92. agentic_devtools-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,34 @@
1
+ """Compatibility shim for the legacy agdt_ai_helpers package name."""
2
+
3
+ import importlib.abc
4
+ import importlib.util
5
+ from importlib import import_module
6
+ import sys as _sys
7
+
8
+ _new_pkg = import_module("agentic_devtools")
9
+ _sys.modules[__name__] = _new_pkg
10
+
11
+
12
+ class _AliasFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
13
+ """Redirect agdt_ai_helpers.* imports to agentic_devtools.* modules."""
14
+
15
+ def find_spec(self, fullname, path, target=None):
16
+ if not fullname.startswith("agdt_ai_helpers."):
17
+ return None
18
+ target_name = "agentic_devtools" + fullname[len("agdt_ai_helpers") :]
19
+ target_spec = importlib.util.find_spec(target_name)
20
+ if target_spec is None:
21
+ return None
22
+ return importlib.util.spec_from_loader(fullname, self)
23
+
24
+ def create_module(self, spec): # pragma: no cover - default module creation
25
+ return None
26
+
27
+ def exec_module(self, module):
28
+ target_name = "agentic_devtools" + module.__name__[len("agdt_ai_helpers") :]
29
+ target_module = import_module(target_name)
30
+ _sys.modules[module.__name__] = target_module
31
+
32
+
33
+ if not any(isinstance(finder, _AliasFinder) for finder in _sys.meta_path):
34
+ _sys.meta_path.insert(0, _AliasFinder())
@@ -0,0 +1,8 @@
1
+ """
2
+ agentic-devtools: AI assistant helper commands for the Dragonfly platform.
3
+
4
+ This package provides simple CLI commands that can be easily auto-approved
5
+ by VS Code AI assistants.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,598 @@
1
+ """
2
+ Background task execution infrastructure.
3
+
4
+ Provides functionality to run CLI commands in detached background processes
5
+ with output logging and status tracking.
6
+ """
7
+
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from .task_state import (
15
+ BackgroundTask,
16
+ TaskStatus,
17
+ add_task,
18
+ get_logs_dir,
19
+ get_task_by_id,
20
+ )
21
+
22
+ # Log file format
23
+ LOG_FILE_FORMAT = "{command}_{timestamp}.log"
24
+
25
+
26
+ def create_log_file_path(command: str) -> Path:
27
+ """
28
+ Create a unique log file path for a command.
29
+
30
+ Args:
31
+ command: The command name (e.g., "agdt-git-save-work")
32
+
33
+ Returns:
34
+ Path to the new log file
35
+ """
36
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
37
+ # Sanitize command name for filename
38
+ safe_command = command.replace("-", "_").replace(" ", "_")
39
+ filename = LOG_FILE_FORMAT.format(command=safe_command, timestamp=timestamp)
40
+ return get_logs_dir() / filename
41
+
42
+
43
+ def _get_python_executable() -> str:
44
+ """Get the current Python executable path."""
45
+ return sys.executable
46
+
47
+
48
+ def _build_runner_script(
49
+ command: str,
50
+ task_id: str,
51
+ log_file: Path,
52
+ cwd: Optional[str] = None,
53
+ ) -> str:
54
+ """
55
+ Build a Python script that runs the command and updates task state.
56
+
57
+ This script is executed in the background subprocess and handles:
58
+ - Redirecting output to log file
59
+ - Updating task status in state
60
+ - Capturing exit codes
61
+
62
+ Args:
63
+ command: The CLI command to run
64
+ task_id: The task ID for state updates
65
+ log_file: Path to write logs to
66
+ cwd: Working directory for the command
67
+
68
+ Returns:
69
+ Python script as a string
70
+ """
71
+ # Get the path to this package for imports
72
+ package_dir = Path(__file__).parent.parent
73
+
74
+ script = f"""
75
+ import json
76
+ import os
77
+ import subprocess
78
+ import sys
79
+ from datetime import datetime, timezone
80
+ from pathlib import Path
81
+
82
+ # Add package to path for imports
83
+ sys.path.insert(0, {repr(str(package_dir))})
84
+
85
+ from agentic_devtools.task_state import (
86
+ get_task_by_id,
87
+ update_task,
88
+ TaskStatus,
89
+ )
90
+
91
+ def main():
92
+ task_id = {repr(task_id)}
93
+ log_file = Path({repr(str(log_file))})
94
+ command = {repr(command)}
95
+ cwd = {repr(cwd)}
96
+
97
+ # Ensure log directory exists
98
+ log_file.parent.mkdir(parents=True, exist_ok=True)
99
+
100
+ # Open log file for writing
101
+ with open(log_file, "w", encoding="utf-8") as log:
102
+ # Write header
103
+ start_time = datetime.now(timezone.utc).isoformat()
104
+ log.write(f"=== Task {{task_id}} ===\\n")
105
+ log.write(f"Command: {{command}}\\n")
106
+ log.write(f"Started: {{start_time}}\\n")
107
+ log.write(f"Working Directory: {{cwd or os.getcwd()}}\\n")
108
+ log.write("=" * 50 + "\\n\\n")
109
+ log.flush()
110
+
111
+ # Update task to running status
112
+ task = get_task_by_id(task_id)
113
+ if task:
114
+ task.mark_running()
115
+ update_task(task)
116
+
117
+ # Run the command with UTF-8 encoding for child process stdout
118
+ env = os.environ.copy()
119
+ env["PYTHONIOENCODING"] = "utf-8"
120
+ try:
121
+ result = subprocess.run(
122
+ command,
123
+ shell=True,
124
+ stdout=log,
125
+ stderr=subprocess.STDOUT,
126
+ cwd=cwd,
127
+ text=True,
128
+ encoding="utf-8",
129
+ errors="replace",
130
+ env=env,
131
+ )
132
+ exit_code = result.returncode
133
+ error_message = None
134
+ except Exception as e:
135
+ exit_code = 1
136
+ error_message = str(e)
137
+ log.write(f"\\n\\n!!! Exception: {{error_message}}\\n")
138
+
139
+ # Write footer
140
+ end_time = datetime.now(timezone.utc).isoformat()
141
+ log.write("\\n" + "=" * 50 + "\\n")
142
+ log.write(f"Completed: {{end_time}}\\n")
143
+ log.write(f"Exit Code: {{exit_code}}\\n")
144
+ log.flush()
145
+
146
+ # Update task to completed/failed status
147
+ task = get_task_by_id(task_id)
148
+ if task:
149
+ if exit_code == 0:
150
+ task.mark_completed(exit_code)
151
+ else:
152
+ task.mark_failed(exit_code, error_message)
153
+ update_task(task)
154
+
155
+ sys.exit(exit_code)
156
+
157
+ if __name__ == "__main__":
158
+ main()
159
+ """
160
+ return script
161
+
162
+
163
+ def run_in_background(
164
+ command: str,
165
+ args: Optional[Dict[str, Any]] = None,
166
+ cwd: Optional[str] = None,
167
+ ) -> BackgroundTask:
168
+ """
169
+ Run a CLI command in a detached background process.
170
+
171
+ The command runs in a separate process that:
172
+ - Continues running even if the parent process exits
173
+ - Writes all output to a log file
174
+ - Updates task state with progress and completion
175
+
176
+ Args:
177
+ command: The CLI command to run (e.g., "agdt-git-save-work")
178
+ args: Additional arguments/context to store with the task
179
+ cwd: Working directory for the command
180
+
181
+ Returns:
182
+ BackgroundTask object representing the spawned task
183
+ """
184
+ # Create log file path
185
+ log_file = create_log_file_path(command)
186
+
187
+ # Create task record
188
+ task = BackgroundTask.create(
189
+ command=command,
190
+ log_file=log_file,
191
+ args=args,
192
+ )
193
+
194
+ # Add task to state
195
+ add_task(task)
196
+
197
+ # Build runner script
198
+ runner_script = _build_runner_script(
199
+ command=command,
200
+ task_id=task.id,
201
+ log_file=log_file,
202
+ cwd=cwd,
203
+ )
204
+
205
+ # Get Python executable
206
+ python_exe = _get_python_executable()
207
+
208
+ # Set up environment with UTF-8 encoding for child process
209
+ import os as _os
210
+
211
+ env = _os.environ.copy()
212
+ env["PYTHONIOENCODING"] = "utf-8"
213
+
214
+ # Launch detached process
215
+ if sys.platform == "win32":
216
+ # Windows: Use CREATE_NO_WINDOW to prevent console windows from opening
217
+ # Combined with CREATE_NEW_PROCESS_GROUP for proper signal handling
218
+ creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
219
+ subprocess.Popen(
220
+ [python_exe, "-c", runner_script],
221
+ creationflags=creation_flags,
222
+ stdout=subprocess.DEVNULL,
223
+ stderr=subprocess.DEVNULL,
224
+ stdin=subprocess.DEVNULL,
225
+ close_fds=True,
226
+ cwd=cwd,
227
+ env=env,
228
+ )
229
+ else:
230
+ # Unix: Use double-fork or nohup-like behavior
231
+ subprocess.Popen(
232
+ [python_exe, "-c", runner_script],
233
+ stdout=subprocess.DEVNULL,
234
+ stderr=subprocess.DEVNULL,
235
+ stdin=subprocess.DEVNULL,
236
+ start_new_session=True,
237
+ close_fds=True,
238
+ cwd=cwd,
239
+ env=env,
240
+ )
241
+
242
+ return task
243
+
244
+
245
+ def _build_function_runner_script(
246
+ module_path: str,
247
+ function_name: str,
248
+ task_id: str,
249
+ log_file: Path,
250
+ cwd: Optional[str] = None,
251
+ ) -> str:
252
+ """
253
+ Build a Python script that imports and runs a function, updating task state.
254
+
255
+ Args:
256
+ module_path: Full module path (e.g., "agentic_devtools.cli.jira.comment_commands")
257
+ function_name: Name of the function to call (e.g., "add_comment")
258
+ task_id: The task ID for state updates
259
+ log_file: Path to write logs to
260
+ cwd: Working directory for the function
261
+
262
+ Returns:
263
+ Python script as a string
264
+ """
265
+ package_dir = Path(__file__).parent.parent
266
+
267
+ script = f"""
268
+ import io
269
+ import os
270
+ import sys
271
+ import traceback
272
+ from contextlib import redirect_stdout, redirect_stderr
273
+ from datetime import datetime, timezone
274
+ from pathlib import Path
275
+
276
+ # Add package to path for imports
277
+ sys.path.insert(0, {repr(str(package_dir))})
278
+
279
+ from agentic_devtools.task_state import (
280
+ get_task_by_id,
281
+ update_task,
282
+ TaskStatus,
283
+ )
284
+
285
+ def main():
286
+ task_id = {repr(task_id)}
287
+ log_file = Path({repr(str(log_file))})
288
+ module_path = {repr(module_path)}
289
+ function_name = {repr(function_name)}
290
+ cwd = {repr(cwd)}
291
+
292
+ # Change to working directory if specified
293
+ if cwd:
294
+ os.chdir(cwd)
295
+
296
+ # Ensure log directory exists
297
+ log_file.parent.mkdir(parents=True, exist_ok=True)
298
+
299
+ # Open log file for writing
300
+ with open(log_file, "w", encoding="utf-8") as log:
301
+ # Write header
302
+ start_time = datetime.now(timezone.utc).isoformat()
303
+ log.write(f"=== Task {{task_id}} ===\\n")
304
+ log.write(f"Function: {{module_path}}.{{function_name}}\\n")
305
+ log.write(f"Started: {{start_time}}\\n")
306
+ log.write(f"Working Directory: {{os.getcwd()}}\\n")
307
+ log.write("=" * 50 + "\\n\\n")
308
+ log.flush()
309
+
310
+ # Update task to running status
311
+ task = get_task_by_id(task_id)
312
+ if task:
313
+ task.mark_running()
314
+ update_task(task)
315
+
316
+ # Capture stdout/stderr to log
317
+ exit_code = 0
318
+ error_message = None
319
+
320
+ try:
321
+ # Import the module and get the function
322
+ import importlib
323
+ module = importlib.import_module(module_path)
324
+ func = getattr(module, function_name)
325
+
326
+ # Redirect stdout/stderr to log file
327
+ # Create a writer that writes to both the log and flushes
328
+ class LogWriter:
329
+ def __init__(self, log_file):
330
+ self.log = log_file
331
+ def write(self, text):
332
+ self.log.write(text)
333
+ self.log.flush()
334
+ def flush(self):
335
+ self.log.flush()
336
+
337
+ log_writer = LogWriter(log)
338
+
339
+ with redirect_stdout(log_writer), redirect_stderr(log_writer):
340
+ # Call the function
341
+ result = func()
342
+ # If function returns an int, use as exit code
343
+ if isinstance(result, int):
344
+ exit_code = result
345
+
346
+ except SystemExit as e:
347
+ # Function called sys.exit()
348
+ exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
349
+ except Exception as e:
350
+ exit_code = 1
351
+ error_message = str(e)
352
+ log.write(f"\\n\\n!!! Exception: {{error_message}}\\n")
353
+ log.write(traceback.format_exc())
354
+
355
+ # Write footer
356
+ end_time = datetime.now(timezone.utc).isoformat()
357
+ log.write("\\n" + "=" * 50 + "\\n")
358
+ log.write(f"Completed: {{end_time}}\\n")
359
+ log.write(f"Exit Code: {{exit_code}}\\n")
360
+ log.flush()
361
+
362
+ # Update task to completed/failed status
363
+ task = get_task_by_id(task_id)
364
+ if task:
365
+ if exit_code == 0:
366
+ task.mark_completed(exit_code)
367
+ else:
368
+ task.mark_failed(exit_code, error_message)
369
+ update_task(task)
370
+
371
+ sys.exit(exit_code)
372
+
373
+ if __name__ == "__main__":
374
+ main()
375
+ """
376
+ return script
377
+
378
+
379
+ def run_function_in_background(
380
+ module_path: str,
381
+ function_name: str,
382
+ command_display_name: Optional[str] = None,
383
+ args: Optional[Dict[str, Any]] = None,
384
+ cwd: Optional[str] = None,
385
+ ) -> BackgroundTask:
386
+ """
387
+ Run a Python function in a detached background process.
388
+
389
+ The function is imported and called directly, with stdout/stderr
390
+ captured to a log file.
391
+
392
+ Args:
393
+ module_path: Full module path (e.g., "agentic_devtools.cli.jira.comment_commands")
394
+ function_name: Name of the function to call (e.g., "add_comment")
395
+ command_display_name: Human-readable name for the task (defaults to function_name)
396
+ args: Additional arguments/context to store with the task
397
+ cwd: Working directory for the function
398
+
399
+ Returns:
400
+ BackgroundTask object representing the spawned task
401
+
402
+ Example:
403
+ task = run_function_in_background(
404
+ "agentic_devtools.cli.jira.comment_commands",
405
+ "add_comment",
406
+ command_display_name="agdt-add-jira-comment"
407
+ )
408
+ """
409
+ display_name = command_display_name or function_name
410
+
411
+ # Create log file path
412
+ log_file = create_log_file_path(display_name)
413
+
414
+ # Create task record
415
+ task = BackgroundTask.create(
416
+ command=display_name,
417
+ log_file=log_file,
418
+ args=args,
419
+ )
420
+
421
+ # Add task to state
422
+ add_task(task)
423
+
424
+ # Build runner script
425
+ runner_script = _build_function_runner_script(
426
+ module_path=module_path,
427
+ function_name=function_name,
428
+ task_id=task.id,
429
+ log_file=log_file,
430
+ cwd=cwd,
431
+ )
432
+
433
+ # Get Python executable
434
+ python_exe = _get_python_executable()
435
+
436
+ # Set up environment with UTF-8 encoding for child process
437
+ import os as _os
438
+
439
+ env = _os.environ.copy()
440
+ env["PYTHONIOENCODING"] = "utf-8"
441
+
442
+ # Launch detached process
443
+ if sys.platform == "win32":
444
+ # Windows: Use CREATE_NO_WINDOW to prevent console windows from opening
445
+ # Combined with CREATE_NEW_PROCESS_GROUP for proper signal handling
446
+ creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
447
+ subprocess.Popen(
448
+ [python_exe, "-c", runner_script],
449
+ creationflags=creation_flags,
450
+ stdout=subprocess.DEVNULL,
451
+ stderr=subprocess.DEVNULL,
452
+ stdin=subprocess.DEVNULL,
453
+ close_fds=True,
454
+ cwd=cwd,
455
+ env=env,
456
+ )
457
+ else:
458
+ subprocess.Popen(
459
+ [python_exe, "-c", runner_script],
460
+ stdout=subprocess.DEVNULL,
461
+ stderr=subprocess.DEVNULL,
462
+ stdin=subprocess.DEVNULL,
463
+ start_new_session=True,
464
+ close_fds=True,
465
+ cwd=cwd,
466
+ env=env,
467
+ )
468
+
469
+ return task
470
+
471
+
472
+ def wait_for_task(
473
+ task_id: str,
474
+ poll_interval: float = 0.5,
475
+ timeout: Optional[float] = None,
476
+ ) -> Tuple[bool, Optional[int]]:
477
+ """
478
+ Wait for a background task to complete.
479
+
480
+ Args:
481
+ task_id: ID of the task to wait for
482
+ poll_interval: Seconds between status checks
483
+ timeout: Maximum seconds to wait (None for infinite)
484
+
485
+ Returns:
486
+ Tuple of (success, exit_code):
487
+ - success: True if task completed successfully
488
+ - exit_code: The task's exit code (None if timeout or not found)
489
+ """
490
+ import time
491
+
492
+ start_time = time.time()
493
+
494
+ while True:
495
+ task = get_task_by_id(task_id)
496
+
497
+ if task is None:
498
+ return (False, None)
499
+
500
+ if task.is_terminal():
501
+ return (task.status == TaskStatus.COMPLETED, task.exit_code)
502
+
503
+ if timeout is not None and (time.time() - start_time) > timeout:
504
+ return (False, None)
505
+
506
+ time.sleep(poll_interval)
507
+
508
+
509
+ def get_task_log_content(task_id: str, tail_lines: Optional[int] = None) -> Optional[str]:
510
+ """
511
+ Get the content of a task's log file.
512
+
513
+ Args:
514
+ task_id: ID of the task
515
+ tail_lines: If specified, only return the last N lines
516
+
517
+ Returns:
518
+ Log file content as string, or None if task/log not found
519
+ """
520
+ task = get_task_by_id(task_id)
521
+
522
+ if task is None or task.log_file is None:
523
+ return None
524
+
525
+ log_path = Path(task.log_file)
526
+
527
+ if not log_path.exists():
528
+ return None
529
+
530
+ try:
531
+ content = log_path.read_text(encoding="utf-8", errors="replace")
532
+
533
+ if tail_lines is not None:
534
+ lines = content.splitlines()
535
+ content = "\n".join(lines[-tail_lines:])
536
+
537
+ return content
538
+ except OSError:
539
+ return None
540
+
541
+
542
+ def cleanup_old_logs(max_age_hours: float = 24, max_count: Optional[int] = None) -> int:
543
+ """
544
+ Clean up old log files.
545
+
546
+ Args:
547
+ max_age_hours: Delete logs older than this many hours
548
+ max_count: Keep at most this many log files (oldest deleted first)
549
+
550
+ Returns:
551
+ Number of log files deleted
552
+ """
553
+ from datetime import timedelta
554
+
555
+ logs_dir = get_logs_dir()
556
+ deleted_count = 0
557
+
558
+ if not logs_dir.exists():
559
+ return 0
560
+
561
+ # Get all log files with their modification times
562
+ log_files: List[Tuple[Path, float]] = []
563
+ for log_file in logs_dir.glob("*.log"):
564
+ try:
565
+ mtime = log_file.stat().st_mtime
566
+ log_files.append((log_file, mtime))
567
+ except OSError:
568
+ continue
569
+
570
+ # Sort by modification time (oldest first)
571
+ log_files.sort(key=lambda x: x[1])
572
+
573
+ cutoff_time = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
574
+ cutoff_timestamp = cutoff_time.timestamp()
575
+
576
+ # Delete old files
577
+ for log_file, mtime in log_files:
578
+ if mtime < cutoff_timestamp:
579
+ try:
580
+ log_file.unlink()
581
+ deleted_count += 1
582
+ except OSError:
583
+ pass
584
+
585
+ # Delete excess files if max_count specified
586
+ if max_count is not None:
587
+ remaining = [f for f in logs_dir.glob("*.log")]
588
+ remaining.sort(key=lambda x: x.stat().st_mtime)
589
+
590
+ while len(remaining) > max_count:
591
+ oldest = remaining.pop(0)
592
+ try:
593
+ oldest.unlink()
594
+ deleted_count += 1
595
+ except OSError:
596
+ pass
597
+
598
+ return deleted_count
@@ -0,0 +1 @@
1
+ """CLI modules for agentic-devtools."""