devagent-ai 0.3.1__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.
- agent/__init__.py +5 -0
- agent/llm.py +6 -0
- agent/loop.py +16 -0
- agent/memory.py +5 -0
- agent/prompts.py +3 -0
- agent/tools.py +6 -0
- devagent/__init__.py +3 -0
- devagent/__main__.py +4 -0
- devagent/artifacts.py +46 -0
- devagent/cli.py +164 -0
- devagent/config.py +72 -0
- devagent/discovery.py +504 -0
- devagent/evaluation.py +388 -0
- devagent/memory.py +51 -0
- devagent/models.py +251 -0
- devagent/orchestrator.py +887 -0
- devagent/providers.py +344 -0
- devagent/report.py +52 -0
- devagent/retrieval.py +478 -0
- devagent/safety.py +131 -0
- devagent/state_machine.py +45 -0
- devagent/tasking.py +83 -0
- devagent/workspace.py +199 -0
- devagent/worktree.py +151 -0
- devagent_ai-0.3.1.dist-info/METADATA +415 -0
- devagent_ai-0.3.1.dist-info/RECORD +31 -0
- devagent_ai-0.3.1.dist-info/WHEEL +5 -0
- devagent_ai-0.3.1.dist-info/entry_points.txt +2 -0
- devagent_ai-0.3.1.dist-info/licenses/LICENSE +21 -0
- devagent_ai-0.3.1.dist-info/licenses/NOTICE +10 -0
- devagent_ai-0.3.1.dist-info/top_level.txt +2 -0
devagent/providers.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Callable, Iterable, Protocol
|
|
10
|
+
|
|
11
|
+
from devagent.config import ProviderConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProviderError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StructuredResponseError(ProviderError):
|
|
19
|
+
"""A bounded, safe description of malformed provider output."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelProvider(Protocol):
|
|
23
|
+
def request(self, *, role: str, payload: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_SYSTEM = """You are one role inside DevAgent, a local evidence-driven engineering system.
|
|
27
|
+
Output exactly one JSON object matching the supplied JSON schema; the schema is authoritative.
|
|
28
|
+
Every confidence value must be a number from 0.0 through 1.0, never low/medium/high.
|
|
29
|
+
Return no markdown and no extra fields. Use only supplied repository evidence, and never claim
|
|
30
|
+
commands ran; the deterministic harness runs commands and evaluates exit codes."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class _Violation:
|
|
35
|
+
path: str
|
|
36
|
+
expected: str
|
|
37
|
+
actual: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _actual(value: Any, *, path: str) -> str:
|
|
41
|
+
if value is None:
|
|
42
|
+
return "null"
|
|
43
|
+
if isinstance(value, bool):
|
|
44
|
+
return f"boolean {value!r}"
|
|
45
|
+
if isinstance(value, (int, float)):
|
|
46
|
+
return f"{type(value).__name__} {value!r}"
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
lowered = value.lower()
|
|
49
|
+
if path.endswith("confidence") and lowered in {"low", "medium", "high"}:
|
|
50
|
+
return f"string {value!r}"
|
|
51
|
+
return f"string(length={len(value)})"
|
|
52
|
+
if isinstance(value, list):
|
|
53
|
+
return f"array(length={len(value)})"
|
|
54
|
+
if isinstance(value, dict):
|
|
55
|
+
return f"object(field_count={len(value)})"
|
|
56
|
+
return type(value).__name__
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _type_matches(value: Any, expected: str) -> bool:
|
|
60
|
+
if expected == "object":
|
|
61
|
+
return isinstance(value, dict)
|
|
62
|
+
if expected == "array":
|
|
63
|
+
return isinstance(value, list)
|
|
64
|
+
if expected == "string":
|
|
65
|
+
return isinstance(value, str)
|
|
66
|
+
if expected == "number":
|
|
67
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
68
|
+
if expected == "integer":
|
|
69
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
70
|
+
if expected == "boolean":
|
|
71
|
+
return isinstance(value, bool)
|
|
72
|
+
if expected == "null":
|
|
73
|
+
return value is None
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _expected_type(schema: dict[str, Any]) -> str:
|
|
78
|
+
expected = schema.get("type", "value")
|
|
79
|
+
if isinstance(expected, list):
|
|
80
|
+
description = " or ".join(str(item) for item in expected)
|
|
81
|
+
else:
|
|
82
|
+
description = str(expected)
|
|
83
|
+
if "minimum" in schema or "maximum" in schema:
|
|
84
|
+
description += f" between {schema.get('minimum', '-infinity')} and {schema.get('maximum', 'infinity')}"
|
|
85
|
+
if "minLength" in schema:
|
|
86
|
+
description += f" with at least {schema['minLength']} character(s)"
|
|
87
|
+
if "minItems" in schema:
|
|
88
|
+
description += f" with at least {schema['minItems']} item(s)"
|
|
89
|
+
if "enum" in schema:
|
|
90
|
+
description += f" in {schema['enum']!r}"
|
|
91
|
+
if "const" in schema:
|
|
92
|
+
description += f" equal to {schema['const']!r}"
|
|
93
|
+
return description
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate(value: Any, schema: dict[str, Any], path: str = "$") -> _Violation | None:
|
|
97
|
+
alternatives = schema.get("anyOf")
|
|
98
|
+
if isinstance(alternatives, list):
|
|
99
|
+
if any(_validate(value, alternative, path) is None for alternative in alternatives):
|
|
100
|
+
return None
|
|
101
|
+
return _Violation(path, "one of the allowed schema shapes", _actual(value, path=path))
|
|
102
|
+
|
|
103
|
+
expected = schema.get("type")
|
|
104
|
+
expected_types = expected if isinstance(expected, list) else [expected] if expected else []
|
|
105
|
+
if expected_types and not any(_type_matches(value, item) for item in expected_types):
|
|
106
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
107
|
+
|
|
108
|
+
if "const" in schema and value != schema["const"]:
|
|
109
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
110
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
111
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
112
|
+
|
|
113
|
+
if isinstance(value, dict):
|
|
114
|
+
properties = schema.get("properties", {})
|
|
115
|
+
for field in schema.get("required", []):
|
|
116
|
+
if field not in value:
|
|
117
|
+
return _Violation(f"{path}.{field}", "required field", "missing")
|
|
118
|
+
if schema.get("additionalProperties") is False:
|
|
119
|
+
extras = sorted(set(value) - set(properties))
|
|
120
|
+
if extras:
|
|
121
|
+
field = extras[0]
|
|
122
|
+
return _Violation(f"{path}.{field}", "no extra fields", "unexpected field")
|
|
123
|
+
for field, item in value.items():
|
|
124
|
+
if field in properties:
|
|
125
|
+
violation = _validate(item, properties[field], f"{path}.{field}")
|
|
126
|
+
if violation:
|
|
127
|
+
return violation
|
|
128
|
+
|
|
129
|
+
if isinstance(value, list):
|
|
130
|
+
if len(value) < schema.get("minItems", 0):
|
|
131
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
132
|
+
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
133
|
+
return _Violation(path, f"array with at most {schema['maxItems']} item(s)", _actual(value, path=path))
|
|
134
|
+
item_schema = schema.get("items")
|
|
135
|
+
if isinstance(item_schema, dict):
|
|
136
|
+
for index, item in enumerate(value):
|
|
137
|
+
violation = _validate(item, item_schema, f"{path}[{index}]")
|
|
138
|
+
if violation:
|
|
139
|
+
return violation
|
|
140
|
+
|
|
141
|
+
if isinstance(value, str):
|
|
142
|
+
if len(value) < schema.get("minLength", 0):
|
|
143
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
144
|
+
if "pattern" in schema and re.search(schema["pattern"], value) is None:
|
|
145
|
+
return _Violation(path, "non-empty string containing non-whitespace text", _actual(value, path=path))
|
|
146
|
+
|
|
147
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
148
|
+
if not math.isfinite(value):
|
|
149
|
+
return _Violation(path, "finite " + _expected_type(schema), _actual(value, path=path))
|
|
150
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
151
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
152
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
153
|
+
return _Violation(path, _expected_type(schema), _actual(value, path=path))
|
|
154
|
+
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def validate_response(role: str, response: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
|
159
|
+
"""Validate the supported JSON Schema subset without trusting provider enforcement."""
|
|
160
|
+
violation = _validate(response, schema)
|
|
161
|
+
if violation:
|
|
162
|
+
raise StructuredResponseError(
|
|
163
|
+
f"Invalid {role} response at {violation.path}: expected {violation.expected}; received {violation.actual}"
|
|
164
|
+
)
|
|
165
|
+
if not isinstance(response, dict):
|
|
166
|
+
raise StructuredResponseError(f"Invalid {role} response at $: expected object; received {_actual(response, path='$')}")
|
|
167
|
+
return response
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _decode_json(text: str, role: str) -> dict[str, Any]:
|
|
171
|
+
try:
|
|
172
|
+
value = json.loads(text.strip())
|
|
173
|
+
except json.JSONDecodeError as exc:
|
|
174
|
+
raise StructuredResponseError(
|
|
175
|
+
f"Invalid {role} response at $: expected one JSON object; received invalid JSON at line {exc.lineno} column {exc.colno}"
|
|
176
|
+
) from exc
|
|
177
|
+
if not isinstance(value, dict):
|
|
178
|
+
raise StructuredResponseError(
|
|
179
|
+
f"Invalid {role} response at $: expected object; received {_actual(value, path='$')}"
|
|
180
|
+
)
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _user_prompt(role: str, payload: dict[str, Any], schema: dict[str, Any]) -> str:
|
|
185
|
+
return json.dumps({"role": role, "input": payload, "required_output_schema": schema}, ensure_ascii=False)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _repair_prompt(violation: StructuredResponseError) -> str:
|
|
189
|
+
return f"Schema violation only: {violation}. Return corrected JSON matching the supplied schema exactly."
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _request_with_repair(
|
|
193
|
+
role: str,
|
|
194
|
+
schema: dict[str, Any],
|
|
195
|
+
send: Callable[[str | None, str | None], str | dict[str, Any]],
|
|
196
|
+
) -> dict[str, Any]:
|
|
197
|
+
previous: str | None = None
|
|
198
|
+
try:
|
|
199
|
+
raw = send(None, None)
|
|
200
|
+
previous = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
|
|
201
|
+
response = _decode_json(raw, role) if isinstance(raw, str) else raw
|
|
202
|
+
return validate_response(role, response, schema)
|
|
203
|
+
except StructuredResponseError as first_error:
|
|
204
|
+
repair = _repair_prompt(first_error)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
raw = send(repair, previous)
|
|
208
|
+
response = _decode_json(raw, role) if isinstance(raw, str) else raw
|
|
209
|
+
return validate_response(role, response, schema)
|
|
210
|
+
except StructuredResponseError as final_error:
|
|
211
|
+
raise ProviderError(f"{final_error}; one structured-response repair attempt was exhausted") from final_error
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _schema_name(role: str) -> str:
|
|
215
|
+
normalized = re.sub(r"[^a-zA-Z0-9_-]", "_", role)[:40]
|
|
216
|
+
return f"devagent_{normalized}_response"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class OpenAICompatibleProvider:
|
|
221
|
+
config: ProviderConfig
|
|
222
|
+
|
|
223
|
+
def request(self, *, role: str, payload: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
224
|
+
try:
|
|
225
|
+
from openai import OpenAI
|
|
226
|
+
except ImportError as exc: # pragma: no cover - environment dependent
|
|
227
|
+
raise ProviderError("OpenAI SDK is not installed") from exc
|
|
228
|
+
provider = self.config.provider.lower()
|
|
229
|
+
api_key = os.getenv(self.config.api_key_env, "") if self.config.api_key_env else "not-required"
|
|
230
|
+
if not api_key and provider != "compatible":
|
|
231
|
+
raise ProviderError(f"Missing API key environment variable: {self.config.api_key_env}")
|
|
232
|
+
client = OpenAI(api_key=api_key or "local", base_url=self.config.base_url, timeout=self.config.timeout_seconds)
|
|
233
|
+
base_messages = [
|
|
234
|
+
{"role": "system", "content": _SYSTEM},
|
|
235
|
+
{"role": "user", "content": _user_prompt(role, payload, schema)},
|
|
236
|
+
]
|
|
237
|
+
if provider == "openai":
|
|
238
|
+
response_format: dict[str, Any] | None = {
|
|
239
|
+
"type": "json_schema",
|
|
240
|
+
"json_schema": {"name": _schema_name(role), "strict": True, "schema": schema},
|
|
241
|
+
}
|
|
242
|
+
else:
|
|
243
|
+
response_format = {"type": "json_object"}
|
|
244
|
+
compatible_format_supported = True
|
|
245
|
+
|
|
246
|
+
def send(repair: str | None, previous: str | None) -> str:
|
|
247
|
+
nonlocal compatible_format_supported
|
|
248
|
+
messages = list(base_messages)
|
|
249
|
+
if repair is not None:
|
|
250
|
+
messages.extend(
|
|
251
|
+
[
|
|
252
|
+
{"role": "assistant", "content": previous or ""},
|
|
253
|
+
{"role": "user", "content": repair},
|
|
254
|
+
]
|
|
255
|
+
)
|
|
256
|
+
kwargs: dict[str, Any] = {"model": self.config.model, "messages": messages}
|
|
257
|
+
if response_format is not None and (provider == "openai" or compatible_format_supported):
|
|
258
|
+
kwargs["response_format"] = response_format
|
|
259
|
+
try:
|
|
260
|
+
response = client.chat.completions.create(**kwargs)
|
|
261
|
+
except Exception as exc: # pragma: no cover - network/provider dependent
|
|
262
|
+
if provider == "openai" or not compatible_format_supported:
|
|
263
|
+
label = "Provider" if provider == "openai" else "Compatible provider"
|
|
264
|
+
raise ProviderError(f"{label} request failed: {exc}") from exc
|
|
265
|
+
compatible_format_supported = False
|
|
266
|
+
kwargs.pop("response_format", None)
|
|
267
|
+
try:
|
|
268
|
+
response = client.chat.completions.create(**kwargs)
|
|
269
|
+
except Exception as fallback_exc:
|
|
270
|
+
raise ProviderError(f"Compatible provider request failed: {fallback_exc}") from fallback_exc
|
|
271
|
+
content = response.choices[0].message.content
|
|
272
|
+
return content or ""
|
|
273
|
+
|
|
274
|
+
return _request_with_repair(role, schema, send)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass
|
|
278
|
+
class AnthropicProvider:
|
|
279
|
+
config: ProviderConfig
|
|
280
|
+
|
|
281
|
+
def request(self, *, role: str, payload: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
282
|
+
try:
|
|
283
|
+
from anthropic import Anthropic
|
|
284
|
+
except ImportError as exc: # pragma: no cover
|
|
285
|
+
raise ProviderError("Anthropic SDK is not installed") from exc
|
|
286
|
+
api_key = os.getenv(self.config.api_key_env, "")
|
|
287
|
+
if not api_key:
|
|
288
|
+
raise ProviderError(f"Missing API key environment variable: {self.config.api_key_env}")
|
|
289
|
+
client = Anthropic(api_key=api_key, timeout=self.config.timeout_seconds)
|
|
290
|
+
initial_prompt = _user_prompt(role, payload, schema)
|
|
291
|
+
|
|
292
|
+
def send(repair: str | None, previous: str | None) -> str:
|
|
293
|
+
messages = [{"role": "user", "content": initial_prompt}]
|
|
294
|
+
if repair is not None:
|
|
295
|
+
messages.extend(
|
|
296
|
+
[
|
|
297
|
+
{"role": "assistant", "content": previous or ""},
|
|
298
|
+
{"role": "user", "content": repair},
|
|
299
|
+
]
|
|
300
|
+
)
|
|
301
|
+
try:
|
|
302
|
+
response = client.messages.create(
|
|
303
|
+
model=self.config.model,
|
|
304
|
+
max_tokens=4096,
|
|
305
|
+
system=_SYSTEM,
|
|
306
|
+
messages=messages,
|
|
307
|
+
)
|
|
308
|
+
except Exception as exc: # pragma: no cover
|
|
309
|
+
raise ProviderError(f"Anthropic request failed: {exc}") from exc
|
|
310
|
+
return "\n".join(block.text for block in response.content if getattr(block, "type", None) == "text")
|
|
311
|
+
|
|
312
|
+
return _request_with_repair(role, schema, send)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class ScriptedFakeProvider:
|
|
316
|
+
"""Deterministic, credit-free provider used by tests and evaluation fixtures."""
|
|
317
|
+
|
|
318
|
+
def __init__(self, responses: Iterable[dict[str, Any]]) -> None:
|
|
319
|
+
self.responses = deque(responses)
|
|
320
|
+
self.calls: list[dict[str, Any]] = []
|
|
321
|
+
|
|
322
|
+
def request(self, *, role: str, payload: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
323
|
+
def send(repair: str | None, previous: str | None) -> dict[str, Any]:
|
|
324
|
+
call_payload = payload if repair is None else {"schema_violation": repair}
|
|
325
|
+
self.calls.append({"role": role, "payload": call_payload, "schema": schema, "repair": repair is not None})
|
|
326
|
+
if not self.responses:
|
|
327
|
+
raise ProviderError(f"Fake provider has no response remaining for role={role}")
|
|
328
|
+
response = self.responses.popleft()
|
|
329
|
+
if response.get("_role") not in (None, role):
|
|
330
|
+
raise ProviderError(f"Fake response expected role={response['_role']}, received role={role}")
|
|
331
|
+
return {key: value for key, value in response.items() if key != "_role"}
|
|
332
|
+
|
|
333
|
+
return _request_with_repair(role, schema, send)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def create_provider(config: ProviderConfig) -> ModelProvider:
|
|
337
|
+
provider = config.provider.lower()
|
|
338
|
+
if provider in {"anthropic", "claude"}:
|
|
339
|
+
return AnthropicProvider(config)
|
|
340
|
+
if provider in {"openai", "xai", "grok", "compatible"}:
|
|
341
|
+
if provider in {"xai", "grok"} and not config.base_url:
|
|
342
|
+
config = ProviderConfig(provider, config.model, "https://api.x.ai/v1", config.api_key_env, config.timeout_seconds)
|
|
343
|
+
return OpenAICompatibleProvider(config)
|
|
344
|
+
raise ProviderError(f"Unsupported provider: {provider}")
|
devagent/report.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from devagent.models import RunResult
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_report(result: RunResult) -> str:
|
|
7
|
+
passed = [verification for verification in result.verification if verification.passed]
|
|
8
|
+
failed = [verification for verification in result.verification if not verification.passed]
|
|
9
|
+
lines = [
|
|
10
|
+
"DEVAGENT REPORT",
|
|
11
|
+
"",
|
|
12
|
+
"STATUS",
|
|
13
|
+
result.outcome.value,
|
|
14
|
+
"",
|
|
15
|
+
"TASK",
|
|
16
|
+
result.task.goal,
|
|
17
|
+
"",
|
|
18
|
+
"REPOSITORY",
|
|
19
|
+
result.repository.root,
|
|
20
|
+
"",
|
|
21
|
+
"WORKING BRANCH",
|
|
22
|
+
result.repository.git_branch or "(not a Git branch)",
|
|
23
|
+
"",
|
|
24
|
+
"WORKING ROOT",
|
|
25
|
+
result.working_root,
|
|
26
|
+
"",
|
|
27
|
+
"ROOT CAUSE",
|
|
28
|
+
result.root_cause or "Not established",
|
|
29
|
+
"",
|
|
30
|
+
"IMPLEMENTATION",
|
|
31
|
+
]
|
|
32
|
+
lines.extend(f"- {item}" for item in result.implementation or ["No implementation completed"])
|
|
33
|
+
lines.extend(["", "FILES CHANGED"])
|
|
34
|
+
lines.extend(result.changes.paths or ["(none)"])
|
|
35
|
+
lines.extend(["", "VERIFICATION"])
|
|
36
|
+
for verification in passed:
|
|
37
|
+
lines.append(f"✓ {' '.join(verification.command)} ({verification.duration_seconds:.2f}s)")
|
|
38
|
+
for verification in failed:
|
|
39
|
+
classification = verification.classification.value if verification.classification else "UNKNOWN"
|
|
40
|
+
lines.append(f"✗ {' '.join(verification.command)} ({classification})")
|
|
41
|
+
if result.review:
|
|
42
|
+
lines.append("✓ independent review" if result.review.approved else "✗ independent review")
|
|
43
|
+
lines.extend(["", "NEW REGRESSIONS", "0" if result.outcome.value == "VERIFIED" else "Not proven"])
|
|
44
|
+
if result.not_run:
|
|
45
|
+
lines.extend(["", "NOT RUN"])
|
|
46
|
+
lines.extend(f"- {reason}" for reason in result.not_run)
|
|
47
|
+
if result.recommendations:
|
|
48
|
+
lines.extend(["", "RECOMMENDATIONS"])
|
|
49
|
+
lines.extend(f"- {item}" for item in result.recommendations)
|
|
50
|
+
lines.extend(["", "SOURCE CONTROL", "No commit", "No push", "No merge", "", "DEVELOPER ACTION", f"Review: git -C {result.working_root} diff"])
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|