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,163 @@
1
+ """Completion node: post summary comment and finalize workflow.
2
+
3
+ Builds a completion comment with work summary, PR link, completed
4
+ checklist items, and token usage. Posts to the appropriate platform.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from agentic_devtools.cli.github.repo_resolution import resolve_github_repo_safe
12
+ from agentic_devtools.orchestration.nodes._helpers import (
13
+ _to_nonneg_int,
14
+ detect_issue_provider,
15
+ normalize_issue_key,
16
+ run_command,
17
+ utc_now,
18
+ )
19
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
20
+
21
+
22
+ def completion_node(state: WorkOnIssueState) -> dict[str, Any]:
23
+ """Post completion comment and mark workflow as done.
24
+
25
+ Builds a summary with PR link, checklist results, and token usage.
26
+ Posts to Jira or GitHub depending on the issue provider.
27
+
28
+ Fails fast when ``issue_key`` is missing, blank, or not a string so a
29
+ corrupted/resumed checkpoint cannot raise ``AttributeError`` inside
30
+ ``detect_issue_provider()`` or ``normalize_issue_key()``.
31
+ """
32
+ issue_key = state.get("issue_key", "")
33
+ if not isinstance(issue_key, str) or not issue_key.strip():
34
+ return {
35
+ "step": "completion",
36
+ "status": "failed",
37
+ "error": "issue_key is required and must be a non-empty string",
38
+ "events": [
39
+ {
40
+ "event": "completion_failed",
41
+ "timestamp": utc_now(),
42
+ "signals": {"error": "missing_issue_key"},
43
+ }
44
+ ],
45
+ }
46
+ issue_key = issue_key.strip()
47
+ raw_issue_provider = state.get("issue_provider")
48
+ issue_provider = (
49
+ raw_issue_provider
50
+ if isinstance(raw_issue_provider, str) and raw_issue_provider in {"jira", "github"}
51
+ else detect_issue_provider(issue_key)
52
+ )
53
+ pr_url = state.get("pr_url", "")
54
+ checklist_items = state.get("checklist_items", [])
55
+ token_usage_prompt = state.get("token_usage_prompt", 0)
56
+ token_usage_completion = state.get("token_usage_completion", 0)
57
+
58
+ # Build completion comment
59
+ comment = _build_completion_comment(
60
+ issue_key=issue_key,
61
+ pr_url=pr_url,
62
+ checklist_items=checklist_items if isinstance(checklist_items, list) else [],
63
+ token_usage_prompt=_to_nonneg_int(token_usage_prompt),
64
+ token_usage_completion=_to_nonneg_int(token_usage_completion),
65
+ issue_provider=issue_provider,
66
+ )
67
+
68
+ # Post comment to appropriate platform
69
+ if issue_provider == "jira":
70
+ _post_jira_comment(issue_key, comment)
71
+ else:
72
+ _post_github_comment(issue_key, comment)
73
+
74
+ return {
75
+ "step": "completion",
76
+ "status": "completed",
77
+ "events": [
78
+ {
79
+ "event": "completion_completed",
80
+ "timestamp": utc_now(),
81
+ "signals": {"pr_url": pr_url},
82
+ }
83
+ ],
84
+ }
85
+
86
+
87
+ def _build_completion_comment(
88
+ *,
89
+ issue_key: str,
90
+ pr_url: str,
91
+ checklist_items: list[Any],
92
+ token_usage_prompt: int,
93
+ token_usage_completion: int,
94
+ issue_provider: str = "jira",
95
+ ) -> str:
96
+ """Build the completion comment text."""
97
+ is_github = issue_provider == "github"
98
+ lines = ["#### Autonomous Implementation Complete" if is_github else "h4. Autonomous Implementation Complete"]
99
+ lines.append("")
100
+
101
+ if pr_url:
102
+ lines.append(f"**Pull Request**: {pr_url}" if is_github else f"*Pull Request*: {pr_url}")
103
+ else:
104
+ lines.append("**Pull Request**: Created (see branch)" if is_github else "*Pull Request*: Created (see branch)")
105
+
106
+ lines.append("")
107
+
108
+ # Checklist summary
109
+ completed = sum(1 for item in checklist_items if isinstance(item, dict) and item.get("is_complete"))
110
+ total = len([item for item in checklist_items if isinstance(item, dict)])
111
+ lines.append(
112
+ f"**Checklist**: {completed}/{total} items completed"
113
+ if is_github
114
+ else f"*Checklist*: {completed}/{total} items completed"
115
+ )
116
+ lines.append("")
117
+
118
+ # Token usage
119
+ total_tokens = token_usage_prompt + token_usage_completion
120
+ lines.append("**Token Usage**:" if is_github else "*Token Usage*:")
121
+ lines.append(f"- Prompt tokens: {token_usage_prompt:,}")
122
+ lines.append(f"- Completion tokens: {token_usage_completion:,}")
123
+ lines.append(f"- Total tokens: {total_tokens:,}")
124
+
125
+ lines.append("")
126
+ lines.append("_Generated by LangChain work-on-issue workflow_")
127
+
128
+ return "\n".join(lines)
129
+
130
+
131
+ def _post_jira_comment(issue_key: str, comment: str) -> None:
132
+ """Post completion comment to Jira."""
133
+ issue_result = run_command(["agdt-set", "jira.issue_key", issue_key])
134
+ if issue_result.returncode != 0:
135
+ return
136
+
137
+ comment_result = run_command(["agdt-set", "jira.comment", comment])
138
+ if comment_result.returncode != 0:
139
+ return
140
+
141
+ add_result = run_command(["agdt-add-jira-comment"])
142
+ if add_result.returncode == 0:
143
+ run_command(["agdt-task-wait"], timeout=60)
144
+
145
+
146
+ def _post_github_comment(issue_key: str, comment: str) -> None:
147
+ """Post completion comment to GitHub."""
148
+ try:
149
+ from agentic_devtools.adapters.github_adapter import GitHubIssuesAdapter
150
+
151
+ repo = resolve_github_repo_safe()
152
+ if not repo:
153
+ return
154
+
155
+ normalized_issue_key = normalize_issue_key(issue_key)
156
+ if not normalized_issue_key:
157
+ return
158
+
159
+ adapter = GitHubIssuesAdapter(repo=repo)
160
+ adapter.add_comment(normalized_issue_key, comment)
161
+ except Exception:
162
+ # Best-effort: don't fail the workflow if comment posting fails
163
+ pass
@@ -0,0 +1,395 @@
1
+ """Implementation node: TDD RED/GREEN cycle for each checklist item.
2
+
3
+ Iterates over incomplete checklist items, performing for each:
4
+ 1. RED: Generate a failing test via LLM
5
+ 2. GREEN: Generate implementation code via LLM
6
+ 3. VERIFY: Run tests to confirm they pass
7
+
8
+ Uses repository context discovery to inform LLM prompts.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from agentic_devtools.orchestration.execution.context_factory import _run_async
18
+ from agentic_devtools.orchestration.nodes._helpers import (
19
+ _to_nonneg_int,
20
+ detect_test_conventions,
21
+ read_file_content,
22
+ run_command,
23
+ scan_directory_structure,
24
+ utc_now,
25
+ )
26
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
27
+
28
+
29
+ def implementation_node(state: WorkOnIssueState) -> dict[str, Any]:
30
+ """Execute TDD cycle for each incomplete checklist item.
31
+
32
+ Iterates over checklist items, generating tests (RED) and implementation
33
+ (GREEN) for each. Verifies with ``agdt-test-pattern``.
34
+ """
35
+ checklist_items = state.get("checklist_items", [])
36
+ if not isinstance(checklist_items, list):
37
+ checklist_items = []
38
+
39
+ raw_log = state.get("implementation_log", [])
40
+ implementation_log: list[dict[str, Any]] = raw_log if isinstance(raw_log, list) else []
41
+ raw_paths = state.get("affected_paths", [])
42
+ affected_paths: list[str] = (
43
+ [str(p) for p in raw_paths if not isinstance(p, bool)] if isinstance(raw_paths, list) else []
44
+ )
45
+ error_message: str | None = None
46
+ accumulated_prompt = 0
47
+ accumulated_completion = 0
48
+
49
+ # Process each incomplete item
50
+ for i, item in enumerate(checklist_items):
51
+ if not isinstance(item, dict):
52
+ continue
53
+ if item.get("is_complete"):
54
+ continue
55
+
56
+ try:
57
+ result = _implement_checklist_item(item, i, state)
58
+ except Exception as exc:
59
+ error_message = f"Implementation failed for item {i}: {exc}"
60
+ implementation_log.append(
61
+ {
62
+ "item_index": i,
63
+ "status": "failed",
64
+ "error": str(exc),
65
+ "timestamp": utc_now(),
66
+ }
67
+ )
68
+ break
69
+
70
+ accumulated_prompt += _to_nonneg_int(result.get("token_usage_prompt"))
71
+ accumulated_completion += _to_nonneg_int(result.get("token_usage_completion"))
72
+
73
+ if result.get("error"):
74
+ error_message = result["error"]
75
+ implementation_log.append(
76
+ {
77
+ "item_index": i,
78
+ "status": "failed",
79
+ "error": error_message,
80
+ "timestamp": utc_now(),
81
+ }
82
+ )
83
+ break
84
+
85
+ # Mark item as complete
86
+ checklist_items[i] = {**item, "is_complete": True}
87
+ affected_paths.extend(result.get("affected_paths", []))
88
+ implementation_log.append(
89
+ {
90
+ "item_index": i,
91
+ "status": "completed",
92
+ "affected_paths": result.get("affected_paths", []),
93
+ "timestamp": utc_now(),
94
+ }
95
+ )
96
+
97
+ # Determine if all items are complete. A checklist with no valid (dict) items —
98
+ # an empty list, a non-list value coerced to [], or only corrupted non-dict
99
+ # entries — must NOT be treated as complete: ``all(...)`` over an empty
100
+ # generator returns True, which would let the workflow skip implementation
101
+ # entirely and proceed as if the work were done.
102
+ dict_items = [item for item in checklist_items if isinstance(item, dict)]
103
+ all_complete = bool(dict_items) and all(item.get("is_complete", False) for item in dict_items)
104
+ if not dict_items:
105
+ error_message = "No valid checklist items to implement; cannot mark implementation complete."
106
+
107
+ return {
108
+ "step": "implementation",
109
+ "error": error_message if not all_complete and error_message else None,
110
+ "checklist_items": checklist_items,
111
+ "checklist_complete": all_complete,
112
+ "implementation_log": implementation_log,
113
+ "affected_paths": affected_paths,
114
+ "token_usage_prompt": _to_nonneg_int(state.get("token_usage_prompt")) + accumulated_prompt,
115
+ "token_usage_completion": _to_nonneg_int(state.get("token_usage_completion")) + accumulated_completion,
116
+ "events": [
117
+ {
118
+ "event": "implementation_completed" if all_complete else "implementation_partial",
119
+ "timestamp": utc_now(),
120
+ "signals": {"checklist_complete": all_complete, "error": error_message},
121
+ }
122
+ ],
123
+ }
124
+
125
+
126
+ def _implement_checklist_item(
127
+ item: dict[str, Any],
128
+ index: int,
129
+ state: WorkOnIssueState,
130
+ ) -> dict[str, Any]:
131
+ """Implement a single checklist item using TDD cycle.
132
+
133
+ Returns dict with affected_paths list, accumulated token usage counts,
134
+ or an error string.
135
+ """
136
+ issue_key = state.get("issue_key", "")
137
+ plan = state.get("plan", "")
138
+ description = item.get("description", "")
139
+
140
+ # Discover repository context
141
+ repo_root = _get_repo_root()
142
+ if repo_root is None:
143
+ return {"error": "Cannot determine repository root"}
144
+
145
+ context = _build_context(repo_root)
146
+ prompt_tokens = 0
147
+ completion_tokens = 0
148
+
149
+ # RED phase: generate failing test
150
+ test_result = _generate_test(description, plan, context, issue_key, repo_root)
151
+ test_usage = test_result.get("token_usage", {})
152
+ prompt_tokens += _to_nonneg_int(test_usage.get("prompt_tokens"))
153
+ completion_tokens += _to_nonneg_int(test_usage.get("completion_tokens"))
154
+ if test_result.get("error"):
155
+ return {
156
+ "error": f"RED phase failed: {test_result['error']}",
157
+ "token_usage_prompt": prompt_tokens,
158
+ "token_usage_completion": completion_tokens,
159
+ }
160
+
161
+ test_path = test_result.get("path", "")
162
+ affected = [test_path] if test_path else []
163
+
164
+ # RED verification: confirm the generated test actually fails before writing implementation.
165
+ # A test that immediately passes either has no real assertions or the behavior already exists.
166
+ # pytest exit code 1 means "tests ran and at least one failed" — the expected outcome for a
167
+ # properly-written RED-phase test. Any other non-zero code (2 = usage/syntax error,
168
+ # 3 = internal error, 4/5 = no tests collected) indicates a broken test file.
169
+ if test_path:
170
+ red_verify = run_command(
171
+ ["agdt-test-pattern", test_path, "-v", "-o", "addopts="],
172
+ timeout=120,
173
+ )
174
+ if red_verify.returncode == 0:
175
+ return {
176
+ "error": (
177
+ f"RED phase failed: generated test already passes before implementation; "
178
+ f"the behavior may already exist or the test has no real assertions ({test_path})"
179
+ ),
180
+ "token_usage_prompt": prompt_tokens,
181
+ "token_usage_completion": completion_tokens,
182
+ }
183
+ if red_verify.returncode != 1:
184
+ # Not a clean test failure — exit codes 2+ indicate broken generated test
185
+ # (2 = usage/syntax error, 3 = internal error, 4 = pytest usage error, 5 = no tests collected).
186
+ return {
187
+ "error": (
188
+ f"RED phase failed: generated test is invalid "
189
+ f"(pytest exit {red_verify.returncode}); "
190
+ f"expected exit code 1 (test failures) but got a collection or syntax error. "
191
+ f"Output: {red_verify.stderr[:500]}"
192
+ ),
193
+ "token_usage_prompt": prompt_tokens,
194
+ "token_usage_completion": completion_tokens,
195
+ }
196
+
197
+ # GREEN phase: generate implementation
198
+ impl_result = _generate_implementation(description, plan, context, test_path, issue_key, repo_root)
199
+ impl_usage = impl_result.get("token_usage", {})
200
+ prompt_tokens += _to_nonneg_int(impl_usage.get("prompt_tokens"))
201
+ completion_tokens += _to_nonneg_int(impl_usage.get("completion_tokens"))
202
+ if impl_result.get("error"):
203
+ return {
204
+ "error": f"GREEN phase failed: {impl_result['error']}",
205
+ "token_usage_prompt": prompt_tokens,
206
+ "token_usage_completion": completion_tokens,
207
+ }
208
+
209
+ impl_path = impl_result.get("path", "")
210
+ if impl_path:
211
+ affected.append(impl_path)
212
+
213
+ # VERIFY: run tests
214
+ if test_path:
215
+ verify_result = run_command(
216
+ ["agdt-test-pattern", test_path, "-v", "-o", "addopts="],
217
+ timeout=120,
218
+ )
219
+ if verify_result.returncode != 0:
220
+ return {
221
+ "error": f"VERIFY phase failed: tests did not pass\n{verify_result.stdout}\n{verify_result.stderr}",
222
+ "token_usage_prompt": prompt_tokens,
223
+ "token_usage_completion": completion_tokens,
224
+ }
225
+
226
+ return {
227
+ "affected_paths": affected,
228
+ "token_usage_prompt": prompt_tokens,
229
+ "token_usage_completion": completion_tokens,
230
+ }
231
+
232
+
233
+ def _get_repo_root() -> Path | None:
234
+ """Get the repository root path."""
235
+ result = run_command(["git", "rev-parse", "--show-toplevel"])
236
+ if result.returncode != 0:
237
+ return None
238
+ return Path(result.stdout.strip())
239
+
240
+
241
+ def _build_context(repo_root: Path) -> dict[str, Any]:
242
+ """Build repository context for LLM prompts."""
243
+ structure = scan_directory_structure(repo_root, max_depth=2)
244
+ conventions = detect_test_conventions(repo_root)
245
+ return {
246
+ "structure": structure[:100], # Limit to avoid token overflow
247
+ "conventions": conventions,
248
+ }
249
+
250
+
251
+ def _resolve_output_path(repo_root: Path, file_path: str) -> Path:
252
+ """Resolve an LLM-provided output path within repository bounds."""
253
+ candidate = Path(file_path)
254
+ if candidate.is_absolute():
255
+ raise ValueError(f"Absolute paths are not allowed: {file_path}")
256
+
257
+ resolved = (repo_root / candidate).resolve()
258
+ repo_root_resolved = repo_root.resolve()
259
+
260
+ try:
261
+ resolved.relative_to(repo_root_resolved)
262
+ except ValueError as exc:
263
+ raise ValueError(f"Path traversal is not allowed: {file_path}") from exc
264
+
265
+ return resolved
266
+
267
+
268
+ def _generate_test(
269
+ description: str,
270
+ plan: str,
271
+ context: dict[str, Any],
272
+ issue_key: str,
273
+ repo_root: Path,
274
+ ) -> dict[str, Any]:
275
+ """Generate a failing test file via LLM (RED phase)."""
276
+ from agentic_devtools.orchestration.llm.factory import ProviderFactory
277
+
278
+ factory = ProviderFactory()
279
+ provider = factory.get_provider("implementation", "work_on_issue")
280
+
281
+ conventions = context.get("conventions", {})
282
+ test_layout = conventions.get("test_layout", "1:1:1")
283
+
284
+ system_prompt = (
285
+ "You are a test-driven development assistant. Generate a Python test file "
286
+ "that will initially FAIL (RED phase of TDD). The test should verify the "
287
+ "expected behavior described in the checklist item.\n\n"
288
+ f"Test layout convention: {test_layout}\n"
289
+ "Follow pytest conventions. Include only the test code, no explanations.\n"
290
+ 'Respond with JSON: {"file_path": "relative/path.py", "content": "..."}'
291
+ )
292
+
293
+ user_prompt = f"Issue: {issue_key}\nChecklist item: {description}\nPlan context: {plan[:2000]}"
294
+
295
+ async def _call():
296
+ from agentic_devtools.orchestration.llm.types import LLMMessage
297
+
298
+ messages = [
299
+ LLMMessage(role="system", content=system_prompt),
300
+ LLMMessage(role="user", content=user_prompt),
301
+ ]
302
+ return await provider.complete(messages)
303
+
304
+ token_usage: dict[str, int] = {}
305
+ try:
306
+ response = _run_async(_call())
307
+ if response.usage:
308
+ token_usage = {
309
+ "prompt_tokens": response.usage.input_tokens,
310
+ "completion_tokens": response.usage.output_tokens,
311
+ }
312
+ parsed = json.loads(response.text)
313
+ if not isinstance(parsed, dict):
314
+ return {"error": "LLM did not produce valid test file output", "token_usage": token_usage}
315
+ file_path = parsed.get("file_path", "")
316
+ content = parsed.get("content", "")
317
+
318
+ if file_path and content:
319
+ full_path = _resolve_output_path(repo_root, file_path)
320
+ full_path.parent.mkdir(parents=True, exist_ok=True)
321
+ full_path.write_text(content, encoding="utf-8")
322
+ return {"path": file_path, "token_usage": token_usage}
323
+
324
+ return {"error": "LLM did not produce valid test file output", "token_usage": token_usage}
325
+ except Exception as exc:
326
+ return {"error": str(exc), "token_usage": token_usage}
327
+
328
+
329
+ def _generate_implementation(
330
+ description: str,
331
+ plan: str,
332
+ context: dict[str, Any],
333
+ test_path: str,
334
+ issue_key: str,
335
+ repo_root: Path,
336
+ ) -> dict[str, Any]:
337
+ """Generate implementation code via LLM (GREEN phase)."""
338
+ from agentic_devtools.orchestration.llm.factory import ProviderFactory
339
+
340
+ factory = ProviderFactory()
341
+ provider = factory.get_provider("implementation", "work_on_issue")
342
+
343
+ # Read the test file for context
344
+ test_content = ""
345
+ if test_path:
346
+ test_full_path = repo_root / test_path
347
+ if test_full_path.exists():
348
+ test_content = read_file_content(test_full_path)
349
+
350
+ system_prompt = (
351
+ "You are an implementation assistant. Generate Python source code that makes "
352
+ "the provided failing test pass (GREEN phase of TDD). Write minimal code "
353
+ "to satisfy the test assertions.\n\n"
354
+ 'Respond with JSON: {"file_path": "relative/path.py", "content": "..."}'
355
+ )
356
+
357
+ user_prompt = (
358
+ f"Issue: {issue_key}\n"
359
+ f"Checklist item: {description}\n"
360
+ f"Test file ({test_path}):\n{test_content}\n"
361
+ f"Plan context: {plan[:1000]}"
362
+ )
363
+
364
+ async def _call():
365
+ from agentic_devtools.orchestration.llm.types import LLMMessage
366
+
367
+ messages = [
368
+ LLMMessage(role="system", content=system_prompt),
369
+ LLMMessage(role="user", content=user_prompt),
370
+ ]
371
+ return await provider.complete(messages)
372
+
373
+ token_usage: dict[str, int] = {}
374
+ try:
375
+ response = _run_async(_call())
376
+ if response.usage:
377
+ token_usage = {
378
+ "prompt_tokens": response.usage.input_tokens,
379
+ "completion_tokens": response.usage.output_tokens,
380
+ }
381
+ parsed = json.loads(response.text)
382
+ if not isinstance(parsed, dict):
383
+ return {"error": "LLM did not produce valid implementation output", "token_usage": token_usage}
384
+ file_path = parsed.get("file_path", "")
385
+ content = parsed.get("content", "")
386
+
387
+ if file_path and content:
388
+ full_path = _resolve_output_path(repo_root, file_path)
389
+ full_path.parent.mkdir(parents=True, exist_ok=True)
390
+ full_path.write_text(content, encoding="utf-8")
391
+ return {"path": file_path, "token_usage": token_usage}
392
+
393
+ return {"error": "LLM did not produce valid implementation output", "token_usage": token_usage}
394
+ except Exception as exc:
395
+ return {"error": str(exc), "token_usage": token_usage}
@@ -0,0 +1,126 @@
1
+ """Implementation review node: self-review generated code for quality.
2
+
3
+ Scans generated code for debug statements (breakpoint, pdb, debug prints)
4
+ and TODO/FIXME/HACK/XXX markers. Routes back to implementation
5
+ if issues are found.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from agentic_devtools.orchestration.nodes._helpers import run_command, utc_now
15
+ from agentic_devtools.orchestration.state_schema import WorkOnIssueState
16
+
17
+ # Patterns that indicate leftover debug code
18
+ _DEBUG_PATTERNS = [
19
+ re.compile(r"\bbreakpoint\(\)"),
20
+ re.compile(r"\bpdb\.set_trace\(\)"),
21
+ re.compile(r"\bprint\(.*(debug|DEBUG)"),
22
+ re.compile(r"\bimport pdb\b"),
23
+ re.compile(r"\bimport ipdb\b"),
24
+ ]
25
+
26
+ _TODO_PATTERN = re.compile(r"\b(TODO|FIXME|HACK|XXX)\b", re.IGNORECASE)
27
+
28
+
29
+ def implementation_review_node(state: WorkOnIssueState) -> dict[str, Any]:
30
+ """Review generated code for quality issues.
31
+
32
+ Checks for debug statements, TODO/FIXME markers, and other
33
+ quality concerns. Sets error to route back to implementation
34
+ if issues are found.
35
+ """
36
+ raw_paths = state.get("affected_paths", [])
37
+ affected_paths: list[str] = (
38
+ [str(p) for p in raw_paths if not isinstance(p, bool)] if isinstance(raw_paths, list) else []
39
+ )
40
+ issues: list[str] = []
41
+
42
+ # Get repo root
43
+ result = run_command(["git", "rev-parse", "--show-toplevel"])
44
+ if result.returncode != 0:
45
+ return {
46
+ "step": "implementation_review",
47
+ "error": None,
48
+ "verification_ready": True,
49
+ "events": [
50
+ {
51
+ "event": "implementation_review_completed",
52
+ "timestamp": utc_now(),
53
+ "signals": {"verification_ready": True, "skipped": "no_repo_root"},
54
+ }
55
+ ],
56
+ }
57
+
58
+ repo_root = Path(result.stdout.strip()).resolve()
59
+
60
+ # Scan each affected file
61
+ for path_str in affected_paths:
62
+ # Resolve the joined path and verify it stays inside the repository.
63
+ # A corrupted path containing ".." segments would otherwise escape
64
+ # repo_root and could expose arbitrary files outside the repository.
65
+ # Path.resolve() uses strict=False by default so it never raises OSError;
66
+ # relative_to() raises ValueError when the path escapes repo_root.
67
+ try:
68
+ file_path = (repo_root / path_str).resolve()
69
+ file_path.relative_to(repo_root)
70
+ except ValueError:
71
+ continue
72
+ if not file_path.exists() or not file_path.is_file():
73
+ continue
74
+ if not path_str.endswith(".py"):
75
+ continue
76
+
77
+ try:
78
+ content = file_path.read_text(encoding="utf-8")
79
+ except (OSError, UnicodeDecodeError):
80
+ continue
81
+
82
+ file_issues = _scan_file(path_str, content)
83
+ issues.extend(file_issues)
84
+
85
+ if issues:
86
+ error_msg = "Implementation review found issues:\n" + "\n".join(f"- {i}" for i in issues)
87
+ return {
88
+ "step": "implementation_review",
89
+ "error": error_msg,
90
+ "verification_ready": False,
91
+ "events": [
92
+ {
93
+ "event": "implementation_review_issues_found",
94
+ "timestamp": utc_now(),
95
+ "signals": {"issue_count": len(issues)},
96
+ }
97
+ ],
98
+ }
99
+
100
+ return {
101
+ "step": "implementation_review",
102
+ "error": None,
103
+ "verification_ready": True,
104
+ "events": [
105
+ {
106
+ "event": "implementation_review_completed",
107
+ "timestamp": utc_now(),
108
+ "signals": {"verification_ready": True},
109
+ }
110
+ ],
111
+ }
112
+
113
+
114
+ def _scan_file(path: str, content: str) -> list[str]:
115
+ """Scan a single file for quality issues."""
116
+ issues: list[str] = []
117
+
118
+ for line_num, line in enumerate(content.splitlines(), 1):
119
+ for pattern in _DEBUG_PATTERNS:
120
+ if pattern.search(line):
121
+ issues.append(f"{path}:{line_num}: Debug statement found: {line.strip()}")
122
+
123
+ if _TODO_PATTERN.search(line):
124
+ issues.append(f"{path}:{line_num}: TODO/FIXME marker: {line.strip()}")
125
+
126
+ return issues