agentic-devtools 0.2.332__py3-none-any.whl → 0.2.334__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 (30) hide show
  1. agentic_devtools/_version.py +2 -2
  2. agentic_devtools/adapters/__init__.py +8 -0
  3. agentic_devtools/adapters/idempotency_query_provider.py +64 -0
  4. agentic_devtools/adapters/issue_provider.py +65 -0
  5. agentic_devtools/adapters/jira_provider.py +140 -75
  6. agentic_devtools/adapters/operation_plan.py +111 -0
  7. agentic_devtools/adapters/orchestration_key.py +39 -15
  8. agentic_devtools/adapters/plan_manifest.py +437 -0
  9. agentic_devtools/orchestration/graph_builder.py +15 -5
  10. agentic_devtools/orchestration/nodes/__init__.py +31 -0
  11. agentic_devtools/orchestration/nodes/_helpers.py +261 -0
  12. agentic_devtools/orchestration/nodes/checklist_creation.py +139 -0
  13. agentic_devtools/orchestration/nodes/commit.py +169 -0
  14. agentic_devtools/orchestration/nodes/completion.py +163 -0
  15. agentic_devtools/orchestration/nodes/implementation.py +395 -0
  16. agentic_devtools/orchestration/nodes/implementation_review.py +126 -0
  17. agentic_devtools/orchestration/nodes/initiate.py +136 -0
  18. agentic_devtools/orchestration/nodes/planning.py +221 -0
  19. agentic_devtools/orchestration/nodes/pull_request.py +256 -0
  20. agentic_devtools/orchestration/nodes/retrieve.py +198 -0
  21. agentic_devtools/orchestration/nodes/setup.py +123 -0
  22. agentic_devtools/orchestration/nodes/verification.py +96 -0
  23. agentic_devtools/orchestration/pilot_workflow.py +53 -21
  24. agentic_devtools/orchestration/runner.py +62 -9
  25. agentic_devtools/orchestration/state_schema.py +24 -0
  26. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/METADATA +1 -1
  27. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/RECORD +30 -14
  28. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/WHEEL +0 -0
  29. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/entry_points.txt +0 -0
  30. {agentic_devtools-0.2.332.dist-info → agentic_devtools-0.2.334.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,261 @@
1
+ """Shared helper utilities for work-on-issue node implementations.
2
+
3
+ Provides issue key parsing, provider detection, LLM call wrappers,
4
+ and repository context discovery helpers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import subprocess
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def utc_now() -> str:
17
+ """Return the current UTC time as an ISO-8601 string."""
18
+ return datetime.now(timezone.utc).isoformat()
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Issue key parsing and provider detection
23
+ # ---------------------------------------------------------------------------
24
+
25
+ _JIRA_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9]+-\d+$")
26
+
27
+
28
+ def detect_issue_provider(issue_key: str) -> str:
29
+ """Detect whether an issue key is Jira or GitHub format.
30
+
31
+ Args:
32
+ issue_key: The issue key to classify.
33
+
34
+ Returns:
35
+ ``"jira"`` for PROJECT-NNN format, ``"github"`` for numeric or #N format.
36
+ """
37
+ cleaned_issue_key = issue_key.strip()
38
+ if not cleaned_issue_key:
39
+ return "github"
40
+ # Strip leading # for GitHub issues
41
+ normalized = cleaned_issue_key.lstrip("#")
42
+ if normalized.isdigit():
43
+ return "github"
44
+ jira_candidate = cleaned_issue_key.upper()
45
+ if _JIRA_KEY_PATTERN.match(jira_candidate):
46
+ return "jira"
47
+ return "github"
48
+
49
+
50
+ def normalize_issue_key(issue_key: str) -> str:
51
+ """Normalize an issue key for consistent usage.
52
+
53
+ Strips leading ``#`` characters from GitHub issue numbers.
54
+
55
+ Args:
56
+ issue_key: Raw issue key input.
57
+
58
+ Returns:
59
+ Normalized issue key string.
60
+ """
61
+ cleaned_issue_key = issue_key.strip()
62
+ if cleaned_issue_key.startswith("#"):
63
+ return cleaned_issue_key.lstrip("#")
64
+ return cleaned_issue_key
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Subprocess helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def run_command(
73
+ args: list[str],
74
+ *,
75
+ capture_output: bool = True,
76
+ timeout: int = 300,
77
+ cwd: str | None = None,
78
+ ) -> subprocess.CompletedProcess[str]:
79
+ """Run a subprocess command with standard error handling.
80
+
81
+ Args:
82
+ args: Command and arguments to run.
83
+ capture_output: Whether to capture stdout/stderr.
84
+ timeout: Timeout in seconds.
85
+ cwd: Working directory for the command.
86
+
87
+ Returns:
88
+ CompletedProcess with return code and output.
89
+ """
90
+
91
+ def _to_text(value: bytes | str | None) -> str:
92
+ if isinstance(value, bytes):
93
+ return value.decode("utf-8", errors="replace")
94
+ if isinstance(value, str):
95
+ return value
96
+ return ""
97
+
98
+ try:
99
+ return subprocess.run(
100
+ args,
101
+ capture_output=capture_output,
102
+ text=True,
103
+ timeout=timeout,
104
+ cwd=cwd,
105
+ shell=False,
106
+ )
107
+ except subprocess.TimeoutExpired as exc:
108
+ partial_stdout = _to_text(exc.stdout if exc.stdout is not None else exc.output)
109
+ partial_stderr = _to_text(exc.stderr) if exc.stderr is not None else ""
110
+ timeout_msg = f"Command timed out after {timeout}s: {' '.join(args)}"
111
+ return subprocess.CompletedProcess(
112
+ args=args,
113
+ returncode=124,
114
+ stdout=partial_stdout,
115
+ stderr=f"{timeout_msg}\n{partial_stderr}".rstrip(),
116
+ )
117
+ except FileNotFoundError:
118
+ return subprocess.CompletedProcess(
119
+ args=args,
120
+ returncode=127,
121
+ stdout="",
122
+ stderr=f"Command not found: {args[0] if args else '<empty command>'}",
123
+ )
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # Repository context discovery (FR-010)
128
+ # ---------------------------------------------------------------------------
129
+
130
+
131
+ def scan_directory_structure(
132
+ root: Path,
133
+ *,
134
+ max_depth: int = 3,
135
+ exclude_patterns: tuple[str, ...] = (
136
+ "__pycache__",
137
+ ".git",
138
+ "node_modules",
139
+ ".venv",
140
+ "venv",
141
+ ".agdt",
142
+ ".mypy_cache",
143
+ ".pytest_cache",
144
+ ".ruff_cache",
145
+ ),
146
+ ) -> list[str]:
147
+ """Scan directory structure for repository context.
148
+
149
+ Args:
150
+ root: Root directory to scan.
151
+ max_depth: Maximum directory depth to traverse.
152
+ exclude_patterns: Directory names to exclude.
153
+
154
+ Returns:
155
+ List of relative file paths found.
156
+ """
157
+ if max_depth <= 0:
158
+ return []
159
+
160
+ paths: list[str] = []
161
+ _scan_recursive(root, root, 0, max_depth, exclude_patterns, paths)
162
+ return sorted(paths)
163
+
164
+
165
+ def _scan_recursive(
166
+ root: Path,
167
+ current: Path,
168
+ depth: int,
169
+ max_depth: int,
170
+ exclude_patterns: tuple[str, ...],
171
+ results: list[str],
172
+ ) -> None:
173
+ """Recursively scan directory tree."""
174
+ if depth >= max_depth:
175
+ return
176
+ try:
177
+ entries = sorted(current.iterdir())
178
+ except (PermissionError, FileNotFoundError):
179
+ return
180
+ for entry in entries:
181
+ if entry.name in exclude_patterns:
182
+ continue
183
+ relative = str(entry.relative_to(root))
184
+ if entry.is_file():
185
+ results.append(relative)
186
+ elif entry.is_dir():
187
+ results.append(relative + "/")
188
+ _scan_recursive(root, entry, depth + 1, max_depth, exclude_patterns, results)
189
+
190
+
191
+ def detect_test_conventions(root: Path) -> dict[str, Any]:
192
+ """Detect testing conventions in the repository.
193
+
194
+ Checks for 1:1:1 test layout (tests/unit/) and common test patterns.
195
+
196
+ Args:
197
+ root: Repository root path.
198
+
199
+ Returns:
200
+ Dictionary describing detected test conventions.
201
+ """
202
+ conventions: dict[str, Any] = {
203
+ "has_tests_unit": (root / "tests" / "unit").is_dir(),
204
+ "has_tests_dir": (root / "tests").is_dir(),
205
+ "test_layout": "unknown",
206
+ }
207
+ if conventions["has_tests_unit"]:
208
+ conventions["test_layout"] = "1:1:1"
209
+ elif conventions["has_tests_dir"]:
210
+ conventions["test_layout"] = "flat"
211
+ return conventions
212
+
213
+
214
+ def _to_nonneg_int(value: Any) -> int:
215
+ """Coerce a potentially-corrupted state value to a non-negative integer.
216
+
217
+ Handles ``None``, ``bool``, ``str``, and numeric inputs from checkpoints
218
+ that may have been corrupted or migrated from older state schemas.
219
+ ``bool`` is checked before ``int`` because ``bool`` is a subclass of
220
+ ``int`` and ``True``/``False`` should not be treated as ``1``/``0``.
221
+
222
+ Args:
223
+ value: Arbitrary value from workflow state.
224
+
225
+ Returns:
226
+ Non-negative integer (0 if coercion fails or result is negative).
227
+ """
228
+ if isinstance(value, bool):
229
+ return 0
230
+ if isinstance(value, int):
231
+ return max(0, value)
232
+ if isinstance(value, float):
233
+ return max(0, int(value))
234
+ if isinstance(value, str):
235
+ try:
236
+ return max(0, int(value))
237
+ except ValueError:
238
+ return 0
239
+ return 0
240
+
241
+
242
+ def read_file_content(path: Path, *, max_chars: int = 10000) -> str:
243
+ """Read file content with a character budget.
244
+
245
+ Args:
246
+ path: File path to read.
247
+ max_chars: Maximum characters to return.
248
+
249
+ Returns:
250
+ File content truncated to at most max_chars characters.
251
+ """
252
+ _suffix = "\n... [truncated]"
253
+ try:
254
+ content = path.read_text(encoding="utf-8")
255
+ if len(content) > max_chars:
256
+ if max_chars <= len(_suffix):
257
+ return content[:max_chars]
258
+ return content[: max_chars - len(_suffix)] + _suffix
259
+ return content
260
+ except (OSError, UnicodeDecodeError):
261
+ return ""
@@ -0,0 +1,139 @@
1
+ """Checklist creation node: generate structured checklist from plan.
2
+
3
+ Calls the LLM to decompose the implementation plan into discrete,
4
+ actionable checklist items with acceptance criteria.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from agentic_devtools.orchestration.execution.context_factory import _run_async
13
+ from agentic_devtools.orchestration.nodes._helpers import _to_nonneg_int, utc_now
14
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
15
+
16
+
17
+ def checklist_creation_node(state: WorkOnIssueState) -> dict[str, Any]:
18
+ """Generate structured checklist from the implementation plan.
19
+
20
+ Calls LLM with plan context to produce a list of ChecklistItem entries.
21
+ """
22
+ plan = state.get("plan", "")
23
+ issue_key = state.get("issue_key", "")
24
+
25
+ if not plan:
26
+ return {
27
+ "step": "checklist_creation",
28
+ "error": "No plan available to generate checklist from.",
29
+ "events": [
30
+ {
31
+ "event": "checklist_creation_failed",
32
+ "timestamp": utc_now(),
33
+ "signals": {"error": "no_plan"},
34
+ }
35
+ ],
36
+ }
37
+
38
+ try:
39
+ checklist_result = _generate_checklist(issue_key, plan)
40
+ except Exception as exc:
41
+ return {
42
+ "step": "checklist_creation",
43
+ "error": f"Checklist generation failed: {exc}",
44
+ "events": [
45
+ {
46
+ "event": "checklist_creation_failed",
47
+ "timestamp": utc_now(),
48
+ "signals": {"error": str(exc)},
49
+ }
50
+ ],
51
+ }
52
+
53
+ checklist_items = checklist_result.get("items", [])
54
+ token_usage = checklist_result.get("token_usage", {})
55
+
56
+ return {
57
+ "step": "checklist_creation",
58
+ "error": None,
59
+ "checklist_items": checklist_items,
60
+ "checklist_created": True,
61
+ "token_usage_prompt": _to_nonneg_int(state.get("token_usage_prompt"))
62
+ + _to_nonneg_int(token_usage.get("prompt_tokens")),
63
+ "token_usage_completion": _to_nonneg_int(state.get("token_usage_completion"))
64
+ + _to_nonneg_int(token_usage.get("completion_tokens")),
65
+ "events": [
66
+ {
67
+ "event": "checklist_creation_completed",
68
+ "timestamp": utc_now(),
69
+ "signals": {"item_count": len(checklist_items)},
70
+ }
71
+ ],
72
+ }
73
+
74
+
75
+ def _generate_checklist(issue_key: str, plan: str) -> dict[str, Any]:
76
+ """Generate checklist items via LLM provider.
77
+
78
+ Returns dict with keys: items (list of dicts), token_usage.
79
+ """
80
+ from agentic_devtools.orchestration.llm.factory import ProviderFactory
81
+
82
+ factory = ProviderFactory()
83
+ provider = factory.get_provider("checklist_creation", "work_on_issue")
84
+
85
+ system_prompt = (
86
+ "You are an implementation checklist generator. Given an implementation plan, "
87
+ "break it down into discrete, actionable checklist items. Each item should be "
88
+ "small enough to implement in a single TDD cycle (write test, implement, verify).\n\n"
89
+ "Respond with a JSON object containing:\n"
90
+ '- "items": list of objects, each with:\n'
91
+ ' - "description": what needs to be done\n'
92
+ ' - "acceptance_criteria": how to verify completion\n'
93
+ ' - "estimated_complexity": "low", "medium", or "high"\n'
94
+ ' - "is_complete": false\n'
95
+ )
96
+
97
+ user_prompt = f"Issue: {issue_key}\n\nImplementation Plan:\n{plan}"
98
+
99
+ async def _call_llm():
100
+ from agentic_devtools.orchestration.llm.types import LLMMessage
101
+
102
+ messages = [
103
+ LLMMessage(role="system", content=system_prompt),
104
+ LLMMessage(role="user", content=user_prompt),
105
+ ]
106
+ response = await provider.complete(messages)
107
+ return response
108
+
109
+ response = _run_async(_call_llm())
110
+
111
+ token_usage = {}
112
+ if response.usage:
113
+ token_usage = {
114
+ "prompt_tokens": response.usage.input_tokens,
115
+ "completion_tokens": response.usage.output_tokens,
116
+ }
117
+
118
+ _FALLBACK_ITEM = [
119
+ {
120
+ "description": "Implement the plan as described",
121
+ "acceptance_criteria": "All tests pass",
122
+ "estimated_complexity": "medium",
123
+ "is_complete": False,
124
+ }
125
+ ]
126
+
127
+ try:
128
+ parsed = json.loads(response.text)
129
+ if isinstance(parsed, dict):
130
+ items = parsed.get("items", [])
131
+ if not (isinstance(items, list) and items):
132
+ items = _FALLBACK_ITEM
133
+ else:
134
+ items = _FALLBACK_ITEM
135
+ except (json.JSONDecodeError, TypeError):
136
+ # Fallback: create a single item from the plan
137
+ items = _FALLBACK_ITEM
138
+
139
+ return {"items": items, "token_usage": token_usage}
@@ -0,0 +1,169 @@
1
+ """Commit node: generate commit message and invoke agdt-git-save-work.
2
+
3
+ Creates a conventional commit message following ``type(#NNN): summary``
4
+ for GitHub issues or ``type(PROJECT-NNN): summary`` for Jira keys,
5
+ and uses ``agdt-git-save-work`` which handles smart amend detection.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any
12
+
13
+ from agentic_devtools.orchestration.nodes._helpers import (
14
+ normalize_issue_key,
15
+ run_command,
16
+ utc_now,
17
+ )
18
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
19
+
20
+
21
+ def commit_node(state: WorkOnIssueState) -> dict[str, Any]:
22
+ """Generate commit message and save work.
23
+
24
+ Builds a conventional commit message and invokes ``agdt-git-save-work``
25
+ which handles smart amend detection automatically.
26
+
27
+ Fails fast when ``issue_key`` is missing, blank, or not a string so a
28
+ corrupted/resumed checkpoint cannot generate an invalid ``feat():``
29
+ commit or raise ``AttributeError`` inside ``normalize_issue_key()``.
30
+ """
31
+ issue_key = state.get("issue_key", "")
32
+ if not isinstance(issue_key, str) or not issue_key.strip():
33
+ return {
34
+ "step": "commit",
35
+ "error": "issue_key is required and must be a non-empty string",
36
+ "events": [
37
+ {
38
+ "event": "commit_failed",
39
+ "timestamp": utc_now(),
40
+ "signals": {"error": "missing_issue_key"},
41
+ }
42
+ ],
43
+ }
44
+ normalized_key = normalize_issue_key(issue_key)
45
+ if not normalized_key:
46
+ return {
47
+ "step": "commit",
48
+ "error": "issue_key must normalize to a non-empty issue identifier",
49
+ "events": [
50
+ {
51
+ "event": "commit_failed",
52
+ "timestamp": utc_now(),
53
+ "signals": {"error": "invalid_issue_key"},
54
+ }
55
+ ],
56
+ }
57
+ plan = state.get("plan", "")
58
+ issue_data = state.get("issue_data", {})
59
+
60
+ # Generate commit message
61
+ issue_provider = state.get("issue_provider")
62
+ commit_message = _generate_commit_message(normalized_key, plan, issue_data, issue_provider)
63
+
64
+ # Invoke agdt-git-save-work
65
+ result = run_command(
66
+ ["agdt-git-save-work", "--commit-message", commit_message],
67
+ timeout=120,
68
+ )
69
+
70
+ if result.returncode != 0:
71
+ return {
72
+ "step": "commit",
73
+ "error": f"git save-work failed: {result.stderr.strip()}",
74
+ "commit_message": commit_message,
75
+ "events": [
76
+ {
77
+ "event": "commit_failed",
78
+ "timestamp": utc_now(),
79
+ "signals": {"error": result.stderr.strip()},
80
+ }
81
+ ],
82
+ }
83
+
84
+ # Wait for background task
85
+ wait_result = run_command(["agdt-task-wait"], timeout=120)
86
+ if wait_result.returncode != 0:
87
+ return {
88
+ "step": "commit",
89
+ "error": f"agdt-task-wait failed after commit: {wait_result.stderr.strip()}",
90
+ "commit_message": commit_message,
91
+ "events": [
92
+ {
93
+ "event": "commit_wait_failed",
94
+ "timestamp": utc_now(),
95
+ "signals": {"error": wait_result.stderr.strip()},
96
+ }
97
+ ],
98
+ }
99
+
100
+ return {
101
+ "step": "commit",
102
+ "error": None,
103
+ "commit_created": True,
104
+ "branch_pushed": True,
105
+ "commit_message": commit_message,
106
+ "events": [
107
+ {
108
+ "event": "commit_completed",
109
+ "timestamp": utc_now(),
110
+ "signals": {"commit_created": True, "branch_pushed": True},
111
+ }
112
+ ],
113
+ }
114
+
115
+
116
+ def _generate_commit_message(
117
+ issue_key: str,
118
+ plan: str,
119
+ issue_data: Any,
120
+ issue_provider: str | None = None,
121
+ ) -> str:
122
+ """Generate a conventional commit message.
123
+
124
+ Format: ``feat(#NNN): summary\\n\\nbody\\n\\n#NNN`` for GitHub issues or
125
+ ``feat(PROJECT-NNN): summary\\n\\nbody\\n\\n[PROJECT-NNN](https://<jira>/browse/PROJECT-NNN)``
126
+ for Jira keys.
127
+ """
128
+ summary = ""
129
+ if isinstance(issue_data, dict):
130
+ raw_summary = issue_data.get("summary", "")
131
+ if isinstance(raw_summary, str):
132
+ summary = raw_summary
133
+
134
+ if not isinstance(plan, str):
135
+ plan = ""
136
+
137
+ if not summary:
138
+ # Extract from plan if available
139
+ summary = "implement autonomous workflow"
140
+ if plan:
141
+ first_line = plan.split("\n")[0][:50]
142
+ if first_line:
143
+ summary = first_line.lower()
144
+
145
+ # Determine scope prefix
146
+ normalized = issue_key.lstrip("#")
147
+ if normalized.isdigit():
148
+ scope = f"#{normalized}"
149
+ provider = "github"
150
+ else:
151
+ scope = normalized
152
+ provider = "jira"
153
+
154
+ if isinstance(issue_provider, str) and issue_provider in {"github", "jira"}:
155
+ provider = issue_provider
156
+
157
+ footer = scope
158
+ if provider == "jira":
159
+ jira_base_url = os.environ.get("JIRA_BASE_URL", "https://jira.swica.ch").rstrip("/")
160
+ footer = f"[{scope}]({jira_base_url}/browse/{scope})"
161
+
162
+ title = f"feat({scope}): {summary}"
163
+ # Ensure title is not too long
164
+ if len(title) > 72:
165
+ title = title[:69] + "..."
166
+
167
+ body = f"Autonomous implementation via LangChain work-on-issue workflow.\n\n{footer}"
168
+
169
+ return f"{title}\n\n{body}"