continuous-refactoring 0.1.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.
- continuous_refactoring/__init__.py +74 -0
- continuous_refactoring/__main__.py +8 -0
- continuous_refactoring/agent.py +733 -0
- continuous_refactoring/artifacts.py +431 -0
- continuous_refactoring/cli.py +687 -0
- continuous_refactoring/commit_messages.py +68 -0
- continuous_refactoring/config.py +377 -0
- continuous_refactoring/decisions.py +197 -0
- continuous_refactoring/effort.py +159 -0
- continuous_refactoring/failure_report.py +329 -0
- continuous_refactoring/git.py +134 -0
- continuous_refactoring/loop.py +1137 -0
- continuous_refactoring/migration_manifest_codec.py +190 -0
- continuous_refactoring/migration_tick.py +468 -0
- continuous_refactoring/migrations.py +251 -0
- continuous_refactoring/phases.py +690 -0
- continuous_refactoring/planning.py +588 -0
- continuous_refactoring/prompts.py +900 -0
- continuous_refactoring/refactor_attempts.py +424 -0
- continuous_refactoring/review_cli.py +136 -0
- continuous_refactoring/routing.py +133 -0
- continuous_refactoring/routing_pipeline.py +313 -0
- continuous_refactoring/scope_candidates.py +421 -0
- continuous_refactoring/scope_expansion.py +219 -0
- continuous_refactoring/targeting.py +274 -0
- continuous_refactoring-0.1.0.dist-info/METADATA +272 -0
- continuous_refactoring-0.1.0.dist-info/RECORD +30 -0
- continuous_refactoring-0.1.0.dist-info/WHEEL +4 -0
- continuous_refactoring-0.1.0.dist-info/entry_points.txt +2 -0
- continuous_refactoring-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from continuous_refactoring.decisions import AgentStatus, sanitize_text
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"build_commit_message",
|
|
9
|
+
"commit_rationale",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
_EMPTY_VALUES = frozenset({"none", "n/a", "na"})
|
|
13
|
+
_PLACEHOLDER_SUMMARIES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"ready to commit",
|
|
16
|
+
"validated refactor ready to commit",
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalized_value(text: str) -> str:
|
|
22
|
+
return text.lower().rstrip(".")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _present_text(text: str | None) -> str | None:
|
|
26
|
+
if text is None:
|
|
27
|
+
return None
|
|
28
|
+
stripped = text.strip()
|
|
29
|
+
if not stripped or _normalized_value(stripped) in _EMPTY_VALUES:
|
|
30
|
+
return None
|
|
31
|
+
return stripped
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def commit_rationale(
|
|
35
|
+
status: AgentStatus | None,
|
|
36
|
+
*,
|
|
37
|
+
fallback: str,
|
|
38
|
+
repo_root: Path,
|
|
39
|
+
) -> str:
|
|
40
|
+
if status is not None:
|
|
41
|
+
rationale = _present_text(sanitize_text(status.commit_rationale, repo_root))
|
|
42
|
+
if rationale is not None:
|
|
43
|
+
return rationale
|
|
44
|
+
|
|
45
|
+
summary = _present_text(sanitize_text(status.summary, repo_root))
|
|
46
|
+
if (
|
|
47
|
+
summary is not None
|
|
48
|
+
and _normalized_value(summary) not in _PLACEHOLDER_SUMMARIES
|
|
49
|
+
):
|
|
50
|
+
return summary
|
|
51
|
+
|
|
52
|
+
fallback_text = _present_text(sanitize_text(fallback, repo_root))
|
|
53
|
+
if fallback_text is not None:
|
|
54
|
+
return fallback_text
|
|
55
|
+
return "Validated cleanup completed."
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_commit_message(
|
|
59
|
+
subject: str,
|
|
60
|
+
*,
|
|
61
|
+
why: str,
|
|
62
|
+
validation: str | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
sections = [f"Why:\n{why.strip()}"]
|
|
65
|
+
validation_text = _present_text(validation)
|
|
66
|
+
if validation_text is not None:
|
|
67
|
+
sections.append(f"Validation:\n{validation_text}")
|
|
68
|
+
return f"{subject.strip()}\n\n" + "\n\n".join(sections)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import asdict, dataclass, replace
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
from continuous_refactoring.artifacts import ContinuousRefactorError
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CONFIG_CURRENT_VERSION",
|
|
17
|
+
"ProjectEntry",
|
|
18
|
+
"ResolvedProject",
|
|
19
|
+
"TASTE_CURRENT_VERSION",
|
|
20
|
+
"app_data_dir",
|
|
21
|
+
"config_is_current",
|
|
22
|
+
"default_taste_text",
|
|
23
|
+
"ensure_taste_file",
|
|
24
|
+
"failure_snapshots_dir",
|
|
25
|
+
"find_project",
|
|
26
|
+
"global_dir",
|
|
27
|
+
"load_config_version",
|
|
28
|
+
"load_manifest",
|
|
29
|
+
"load_taste",
|
|
30
|
+
"manifest_path",
|
|
31
|
+
"parse_taste_version",
|
|
32
|
+
"register_project",
|
|
33
|
+
"resolve_live_migrations_dir",
|
|
34
|
+
"resolve_project",
|
|
35
|
+
"save_manifest",
|
|
36
|
+
"set_live_migrations_dir",
|
|
37
|
+
"taste_is_stale",
|
|
38
|
+
"xdg_data_home",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
CONFIG_CURRENT_VERSION = 1
|
|
42
|
+
TASTE_CURRENT_VERSION = 1
|
|
43
|
+
|
|
44
|
+
_DEFAULT_TASTE = """\
|
|
45
|
+
taste-scoping-version: 1
|
|
46
|
+
|
|
47
|
+
- Validate at the edges and stay lean in the middle.
|
|
48
|
+
- Keep exception translation only at real boundaries and preserve causes when translating.
|
|
49
|
+
- Keep comments only when they explain a real boundary contract or a genuinely deferred design issue that code alone cannot make obvious.
|
|
50
|
+
- Remove fallback, compat, adapter, migrated, legacy, or normalize-shaped code when evidence shows it is no longer needed.
|
|
51
|
+
- Merge modules when splits hurt locality more than they help. Split modules when one file hides unrelated responsibilities.
|
|
52
|
+
|
|
53
|
+
## large-scope decisions
|
|
54
|
+
- When to split a module vs. unify related modules.
|
|
55
|
+
- When to introduce or remove an interface or abstraction boundary.
|
|
56
|
+
- When a cross-cutting concern warrants a shared library vs. inline duplication.
|
|
57
|
+
|
|
58
|
+
## rollout style
|
|
59
|
+
- Caution level for changes with wide blast radius.
|
|
60
|
+
- Feature-flag user-visible behavior changes before full rollout.
|
|
61
|
+
- Prefer incremental, reviewable steps over large-bang rewrites.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class ProjectEntry:
|
|
67
|
+
uuid: str
|
|
68
|
+
path: str
|
|
69
|
+
git_remote: str | None
|
|
70
|
+
created_at: str
|
|
71
|
+
live_migrations_dir: str | None = None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ResolvedProject:
|
|
76
|
+
entry: ProjectEntry
|
|
77
|
+
project_dir: Path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Path helpers
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def xdg_data_home() -> Path:
|
|
85
|
+
env = os.environ.get("XDG_DATA_HOME")
|
|
86
|
+
if env:
|
|
87
|
+
return Path(env)
|
|
88
|
+
return Path.home() / ".local" / "share"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def app_data_dir() -> Path:
|
|
92
|
+
return xdg_data_home() / "continuous-refactoring"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def global_dir() -> Path:
|
|
96
|
+
return app_data_dir() / "global"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def manifest_path() -> Path:
|
|
100
|
+
return app_data_dir() / "manifest.json"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Manifest I/O
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _load_manifest_payload() -> dict[str, object]:
|
|
108
|
+
path = manifest_path()
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return {}
|
|
111
|
+
raw_text = _read_manifest_text()
|
|
112
|
+
return _parse_manifest_payload(raw_text)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_manifest_text() -> str:
|
|
116
|
+
try:
|
|
117
|
+
return manifest_path().read_text(encoding="utf-8")
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise ContinuousRefactorError(
|
|
120
|
+
"Manifest file could not be read."
|
|
121
|
+
) from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_manifest_payload(text: str) -> dict[str, object]:
|
|
125
|
+
try:
|
|
126
|
+
raw = json.loads(text)
|
|
127
|
+
except json.JSONDecodeError as exc:
|
|
128
|
+
raise ContinuousRefactorError(
|
|
129
|
+
"Manifest file is malformed: invalid JSON."
|
|
130
|
+
) from exc
|
|
131
|
+
if not isinstance(raw, dict):
|
|
132
|
+
raise ContinuousRefactorError(
|
|
133
|
+
"Manifest file is malformed: expected a JSON object."
|
|
134
|
+
)
|
|
135
|
+
return raw
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _string_field(
|
|
139
|
+
data: Mapping[str, object],
|
|
140
|
+
key: str,
|
|
141
|
+
*,
|
|
142
|
+
project_id: str,
|
|
143
|
+
required: bool,
|
|
144
|
+
) -> str | None:
|
|
145
|
+
value = data.get(key)
|
|
146
|
+
if value is None:
|
|
147
|
+
if required:
|
|
148
|
+
raise ContinuousRefactorError(
|
|
149
|
+
f"Manifest file is malformed: project '{project_id}' missing '{key}'."
|
|
150
|
+
)
|
|
151
|
+
return None
|
|
152
|
+
if not isinstance(value, str):
|
|
153
|
+
raise ContinuousRefactorError(
|
|
154
|
+
f"Manifest file is malformed: project '{project_id}' field '{key}' must be a string."
|
|
155
|
+
)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _entry_from_object(uid: str, data: object) -> ProjectEntry:
|
|
160
|
+
if not isinstance(data, dict):
|
|
161
|
+
raise ContinuousRefactorError(
|
|
162
|
+
f"Manifest file is malformed: project '{uid}' must be a JSON object."
|
|
163
|
+
)
|
|
164
|
+
entry_uuid = _string_field(data, "uuid", project_id=uid, required=True)
|
|
165
|
+
if entry_uuid != uid:
|
|
166
|
+
raise ContinuousRefactorError(
|
|
167
|
+
f"Manifest file is malformed: project '{uid}' uuid mismatch."
|
|
168
|
+
)
|
|
169
|
+
return ProjectEntry(
|
|
170
|
+
uuid=entry_uuid,
|
|
171
|
+
path=_string_field(data, "path", project_id=uid, required=True),
|
|
172
|
+
git_remote=_string_field(
|
|
173
|
+
data,
|
|
174
|
+
"git_remote",
|
|
175
|
+
project_id=uid,
|
|
176
|
+
required=False,
|
|
177
|
+
),
|
|
178
|
+
created_at=_string_field(data, "created_at", project_id=uid, required=True),
|
|
179
|
+
live_migrations_dir=_string_field(
|
|
180
|
+
data,
|
|
181
|
+
"live_migrations_dir",
|
|
182
|
+
project_id=uid,
|
|
183
|
+
required=False,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_manifest() -> dict[str, ProjectEntry]:
|
|
189
|
+
payload = _load_manifest_payload()
|
|
190
|
+
projects_raw = payload.get("projects", {})
|
|
191
|
+
if not isinstance(projects_raw, dict):
|
|
192
|
+
raise ContinuousRefactorError(
|
|
193
|
+
"Manifest file is malformed: 'projects' must be a JSON object."
|
|
194
|
+
)
|
|
195
|
+
return {uid: _entry_from_object(uid, entry) for uid, entry in projects_raw.items()}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def save_manifest(manifest: dict[str, ProjectEntry]) -> None:
|
|
199
|
+
path = manifest_path()
|
|
200
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
payload = {
|
|
202
|
+
"version": CONFIG_CURRENT_VERSION,
|
|
203
|
+
"projects": {uid: asdict(entry) for uid, entry in manifest.items()},
|
|
204
|
+
}
|
|
205
|
+
content = json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
|
206
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
207
|
+
try:
|
|
208
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
209
|
+
fh.write(content)
|
|
210
|
+
os.replace(tmp, str(path))
|
|
211
|
+
except Exception:
|
|
212
|
+
if os.path.exists(tmp):
|
|
213
|
+
os.unlink(tmp)
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def load_config_version() -> int | None:
|
|
218
|
+
payload = _load_manifest_payload()
|
|
219
|
+
version = payload.get("version")
|
|
220
|
+
return version if isinstance(version, int) else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def config_is_current() -> bool:
|
|
224
|
+
return load_config_version() == CONFIG_CURRENT_VERSION
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
# Project lookup / registration
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def find_project(
|
|
232
|
+
path: Path, manifest: dict[str, ProjectEntry]
|
|
233
|
+
) -> ProjectEntry | None:
|
|
234
|
+
for entry in manifest.values():
|
|
235
|
+
if _project_path_matches(path, entry.path):
|
|
236
|
+
return entry
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _project_path_matches(path: Path, stored_path: str) -> bool:
|
|
241
|
+
return path.resolve() == Path(stored_path).resolve()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _detect_git_remote(path: Path) -> str | None:
|
|
245
|
+
try:
|
|
246
|
+
result = subprocess.run(
|
|
247
|
+
["git", "remote", "get-url", "origin"],
|
|
248
|
+
cwd=path,
|
|
249
|
+
capture_output=True,
|
|
250
|
+
text=True,
|
|
251
|
+
check=False,
|
|
252
|
+
)
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
return None
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
return None
|
|
257
|
+
return result.stdout.strip() or None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _resolved(entry: ProjectEntry) -> ResolvedProject:
|
|
261
|
+
project_dir = app_data_dir() / "projects" / entry.uuid
|
|
262
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
return ResolvedProject(entry=entry, project_dir=project_dir)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def failure_snapshots_dir(path: Path) -> Path:
|
|
267
|
+
snapshot_dir = register_project(path).project_dir / "failures"
|
|
268
|
+
snapshot_dir.mkdir(parents=True, exist_ok=True)
|
|
269
|
+
return snapshot_dir
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def register_project(path: Path) -> ResolvedProject:
|
|
273
|
+
resolved = path.resolve()
|
|
274
|
+
manifest = load_manifest()
|
|
275
|
+
existing = find_project(resolved, manifest)
|
|
276
|
+
if existing is not None:
|
|
277
|
+
return _resolved(existing)
|
|
278
|
+
|
|
279
|
+
uid = str(uuid.uuid4())
|
|
280
|
+
entry = ProjectEntry(
|
|
281
|
+
uuid=uid,
|
|
282
|
+
path=str(resolved),
|
|
283
|
+
git_remote=_detect_git_remote(resolved),
|
|
284
|
+
created_at=datetime.now().astimezone().isoformat(timespec="milliseconds"),
|
|
285
|
+
)
|
|
286
|
+
manifest[uid] = entry
|
|
287
|
+
save_manifest(manifest)
|
|
288
|
+
return _resolved(entry)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def resolve_project(path: Path) -> ResolvedProject:
|
|
292
|
+
manifest = load_manifest()
|
|
293
|
+
entry = find_project(path, manifest)
|
|
294
|
+
if entry is None:
|
|
295
|
+
raise ContinuousRefactorError(
|
|
296
|
+
f"Project not registered: {path.resolve()}"
|
|
297
|
+
)
|
|
298
|
+
return _resolved(entry)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def resolve_live_migrations_dir(project: ResolvedProject) -> Path | None:
|
|
302
|
+
if project.entry.live_migrations_dir is None:
|
|
303
|
+
return None
|
|
304
|
+
repo_root = Path(project.entry.path)
|
|
305
|
+
resolved = (repo_root / project.entry.live_migrations_dir).resolve()
|
|
306
|
+
if not resolved.is_relative_to(repo_root):
|
|
307
|
+
raise ContinuousRefactorError(
|
|
308
|
+
f"live_migrations_dir escapes repo: {project.entry.live_migrations_dir}"
|
|
309
|
+
)
|
|
310
|
+
return resolved
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _get_project(manifest: dict[str, ProjectEntry], project_uuid: str) -> ProjectEntry:
|
|
314
|
+
project = manifest.get(project_uuid)
|
|
315
|
+
if project is None:
|
|
316
|
+
raise ContinuousRefactorError(f"Project not registered: {project_uuid}")
|
|
317
|
+
return project
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def set_live_migrations_dir(project_uuid: str, relative_dir: str) -> None:
|
|
321
|
+
manifest = load_manifest()
|
|
322
|
+
old = _get_project(manifest, project_uuid)
|
|
323
|
+
manifest[project_uuid] = replace(old, live_migrations_dir=relative_dir)
|
|
324
|
+
save_manifest(manifest)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ---------------------------------------------------------------------------
|
|
328
|
+
# Taste
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def parse_taste_version(text: str) -> int | None:
|
|
332
|
+
first_line = text.split("\n", 1)[0].strip()
|
|
333
|
+
prefix = "taste-scoping-version:"
|
|
334
|
+
if not first_line.startswith(prefix):
|
|
335
|
+
return None
|
|
336
|
+
raw = first_line[len(prefix):].strip()
|
|
337
|
+
try:
|
|
338
|
+
return int(raw)
|
|
339
|
+
except ValueError:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def taste_is_stale(text: str) -> bool:
|
|
344
|
+
return parse_taste_version(text) != TASTE_CURRENT_VERSION
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def default_taste_text() -> str:
|
|
348
|
+
return _DEFAULT_TASTE
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def ensure_taste_file(path: Path) -> Path:
|
|
352
|
+
if not path.exists():
|
|
353
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
path.write_text(_DEFAULT_TASTE, encoding="utf-8")
|
|
355
|
+
return path
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _read_taste_text(path: Path) -> str:
|
|
359
|
+
try:
|
|
360
|
+
return path.read_text(encoding="utf-8")
|
|
361
|
+
except OSError as exc:
|
|
362
|
+
raise ContinuousRefactorError(
|
|
363
|
+
f"Taste file could not be read: {path}"
|
|
364
|
+
) from exc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def load_taste(project: ResolvedProject | None) -> str:
|
|
368
|
+
if project is not None:
|
|
369
|
+
project_taste = project.project_dir / "taste.md"
|
|
370
|
+
if project_taste.exists():
|
|
371
|
+
return _read_taste_text(project_taste)
|
|
372
|
+
|
|
373
|
+
global_taste = global_dir() / "taste.md"
|
|
374
|
+
if global_taste.exists():
|
|
375
|
+
return _read_taste_text(global_taste)
|
|
376
|
+
|
|
377
|
+
return _DEFAULT_TASTE
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Agent status types and decision records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal, get_args
|
|
9
|
+
|
|
10
|
+
from continuous_refactoring.prompts import (
|
|
11
|
+
CONTINUOUS_REFACTORING_STATUS_BEGIN,
|
|
12
|
+
CONTINUOUS_REFACTORING_STATUS_END,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AgentStatus",
|
|
17
|
+
"DecisionRecord",
|
|
18
|
+
"RouteOutcome",
|
|
19
|
+
"RunnerDecision",
|
|
20
|
+
"RetryRecommendation",
|
|
21
|
+
"default_retry_recommendation",
|
|
22
|
+
"error_failure_kind",
|
|
23
|
+
"parse_status_block",
|
|
24
|
+
"read_status",
|
|
25
|
+
"resolved_phase_reached",
|
|
26
|
+
"sanitize_text",
|
|
27
|
+
"status_summary",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
RunnerDecision = Literal["commit", "retry", "abandon", "blocked"]
|
|
32
|
+
RetryRecommendation = Literal["same-target", "new-target", "none", "human-review"]
|
|
33
|
+
RouteOutcome = Literal["not-routed", "commit", "abandon", "blocked"]
|
|
34
|
+
|
|
35
|
+
_VALID_DECISIONS = frozenset((*get_args(RunnerDecision), None))
|
|
36
|
+
_VALID_RETRY_RECOMMENDATIONS = frozenset(
|
|
37
|
+
(*get_args(RetryRecommendation), None),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class AgentStatus:
|
|
43
|
+
phase_reached: str | None = None
|
|
44
|
+
decision: RunnerDecision | None = None
|
|
45
|
+
retry_recommendation: RetryRecommendation | None = None
|
|
46
|
+
failure_kind: str | None = None
|
|
47
|
+
summary: str | None = None
|
|
48
|
+
commit_rationale: str | None = None
|
|
49
|
+
next_retry_focus: str | None = None
|
|
50
|
+
tests_run: str | None = None
|
|
51
|
+
evidence: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class DecisionRecord:
|
|
56
|
+
decision: RunnerDecision
|
|
57
|
+
retry_recommendation: RetryRecommendation
|
|
58
|
+
target: str
|
|
59
|
+
call_role: str
|
|
60
|
+
phase_reached: str
|
|
61
|
+
failure_kind: str
|
|
62
|
+
summary: str
|
|
63
|
+
next_retry_focus: str | None = None
|
|
64
|
+
retry_used: int = 1
|
|
65
|
+
agent_last_message_path: Path | None = None
|
|
66
|
+
agent_stdout_path: Path | None = None
|
|
67
|
+
agent_stderr_path: Path | None = None
|
|
68
|
+
tests_stdout_path: Path | None = None
|
|
69
|
+
tests_stderr_path: Path | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _status_path_text(path: Path | None) -> str | None:
|
|
73
|
+
if path is None or not path.exists():
|
|
74
|
+
return None
|
|
75
|
+
return path.read_text(encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_status_block(text: str | None) -> AgentStatus | None:
|
|
79
|
+
if not text:
|
|
80
|
+
return None
|
|
81
|
+
begin = text.rfind(CONTINUOUS_REFACTORING_STATUS_BEGIN)
|
|
82
|
+
if begin < 0:
|
|
83
|
+
return None
|
|
84
|
+
end = text.find(CONTINUOUS_REFACTORING_STATUS_END, begin)
|
|
85
|
+
if end < 0:
|
|
86
|
+
return None
|
|
87
|
+
block = text[begin + len(CONTINUOUS_REFACTORING_STATUS_BEGIN):end].strip()
|
|
88
|
+
if not block:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
data: dict[str, str] = {}
|
|
92
|
+
evidence: list[str] = []
|
|
93
|
+
current_key: str | None = None
|
|
94
|
+
for raw_line in block.splitlines():
|
|
95
|
+
line = raw_line.strip()
|
|
96
|
+
if not line:
|
|
97
|
+
continue
|
|
98
|
+
if current_key == "evidence" and line.startswith("- "):
|
|
99
|
+
evidence.append(line[2:].strip())
|
|
100
|
+
continue
|
|
101
|
+
if ":" not in line:
|
|
102
|
+
continue
|
|
103
|
+
key, value = line.split(":", 1)
|
|
104
|
+
current_key = key.strip()
|
|
105
|
+
if current_key == "evidence":
|
|
106
|
+
if value.strip():
|
|
107
|
+
evidence.append(value.strip())
|
|
108
|
+
continue
|
|
109
|
+
data[current_key] = value.strip()
|
|
110
|
+
|
|
111
|
+
decision = data.get("decision", "").lower() or None
|
|
112
|
+
if decision not in _VALID_DECISIONS:
|
|
113
|
+
decision = None
|
|
114
|
+
retry_recommendation = data.get("retry_recommendation", "").lower() or None
|
|
115
|
+
if retry_recommendation not in _VALID_RETRY_RECOMMENDATIONS:
|
|
116
|
+
retry_recommendation = None
|
|
117
|
+
|
|
118
|
+
return AgentStatus(
|
|
119
|
+
phase_reached=data.get("phase_reached") or None,
|
|
120
|
+
decision=decision,
|
|
121
|
+
retry_recommendation=retry_recommendation,
|
|
122
|
+
failure_kind=data.get("failure_kind") or None,
|
|
123
|
+
summary=data.get("summary") or None,
|
|
124
|
+
commit_rationale=data.get("commit_rationale") or None,
|
|
125
|
+
next_retry_focus=data.get("next_retry_focus") or None,
|
|
126
|
+
tests_run=data.get("tests_run") or None,
|
|
127
|
+
evidence=tuple(evidence),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def read_status(
|
|
132
|
+
agent: str,
|
|
133
|
+
*,
|
|
134
|
+
last_message_path: Path | None,
|
|
135
|
+
fallback_text: str | None,
|
|
136
|
+
) -> AgentStatus | None:
|
|
137
|
+
if agent == "codex":
|
|
138
|
+
status = parse_status_block(_status_path_text(last_message_path))
|
|
139
|
+
if status is not None:
|
|
140
|
+
return status
|
|
141
|
+
return parse_status_block(fallback_text)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sanitize_text(text: str | None, repo_root: Path) -> str | None:
|
|
145
|
+
if not text:
|
|
146
|
+
return None
|
|
147
|
+
lines: list[str] = []
|
|
148
|
+
for raw_line in text.splitlines():
|
|
149
|
+
line = raw_line.strip()
|
|
150
|
+
if not line or "codex exec" in line:
|
|
151
|
+
continue
|
|
152
|
+
line = line.replace(str(repo_root), "<repo>")
|
|
153
|
+
line = re.sub(r"/tmp/[^ ]+", "<tmp>", line)
|
|
154
|
+
line = re.sub(r"\s+", " ", line).strip()
|
|
155
|
+
if line:
|
|
156
|
+
lines.append(line)
|
|
157
|
+
if not lines:
|
|
158
|
+
return None
|
|
159
|
+
return " ".join(lines)[:240]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def status_summary(
|
|
163
|
+
status: AgentStatus | None,
|
|
164
|
+
*,
|
|
165
|
+
fallback: str,
|
|
166
|
+
repo_root: Path,
|
|
167
|
+
) -> tuple[str, str | None]:
|
|
168
|
+
summary = sanitize_text(status.summary if status else None, repo_root) or fallback
|
|
169
|
+
focus = sanitize_text(status.next_retry_focus if status else None, repo_root)
|
|
170
|
+
return summary, focus
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def resolved_phase_reached(status: AgentStatus | None, fallback: str) -> str:
|
|
174
|
+
if status is None:
|
|
175
|
+
return fallback
|
|
176
|
+
return status.phase_reached or fallback
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def error_failure_kind(message: str) -> str:
|
|
180
|
+
lowered = message.lower()
|
|
181
|
+
if "timed out" in lowered:
|
|
182
|
+
return "timeout"
|
|
183
|
+
if "produced no output" in lowered:
|
|
184
|
+
return "stuck"
|
|
185
|
+
return "agent-infra-failure"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def default_retry_recommendation(
|
|
189
|
+
decision: RunnerDecision,
|
|
190
|
+
) -> RetryRecommendation:
|
|
191
|
+
if decision == "retry":
|
|
192
|
+
return "same-target"
|
|
193
|
+
if decision == "abandon":
|
|
194
|
+
return "new-target"
|
|
195
|
+
if decision == "blocked":
|
|
196
|
+
return "human-review"
|
|
197
|
+
return "none"
|