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,431 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AttemptStats",
|
|
14
|
+
"CommandCapture",
|
|
15
|
+
"ContinuousRefactorError",
|
|
16
|
+
"RunArtifacts",
|
|
17
|
+
"create_run_artifacts",
|
|
18
|
+
"default_artifacts_root",
|
|
19
|
+
"iso_timestamp",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContinuousRefactorError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_UNSET = object()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CommandCapture:
|
|
32
|
+
command: tuple[str, ...]
|
|
33
|
+
returncode: int
|
|
34
|
+
stdout: str
|
|
35
|
+
stderr: str
|
|
36
|
+
stdout_path: Path
|
|
37
|
+
stderr_path: Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class AttemptStats:
|
|
42
|
+
attempt: int
|
|
43
|
+
target: str | None = None
|
|
44
|
+
retry: int | None = None
|
|
45
|
+
call_role: str | None = None
|
|
46
|
+
phase_reached: str | None = None
|
|
47
|
+
decision: str | None = None
|
|
48
|
+
retry_recommendation: str | None = None
|
|
49
|
+
failure_kind: str | None = None
|
|
50
|
+
failure_summary: str | None = None
|
|
51
|
+
reason_doc_path: str | None = None
|
|
52
|
+
commit_sha: str | None = None
|
|
53
|
+
commit_phase: str | None = None
|
|
54
|
+
requested_effort: str | None = None
|
|
55
|
+
effective_effort: str | None = None
|
|
56
|
+
max_allowed_effort: str | None = None
|
|
57
|
+
effort_source: str | None = None
|
|
58
|
+
effort_capped: bool | None = None
|
|
59
|
+
effort_reason: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class RunArtifacts:
|
|
64
|
+
root: Path
|
|
65
|
+
run_id: str
|
|
66
|
+
repo_root: Path
|
|
67
|
+
agent: str
|
|
68
|
+
model: str
|
|
69
|
+
effort: str
|
|
70
|
+
test_command: str
|
|
71
|
+
events_path: Path
|
|
72
|
+
summary_path: Path
|
|
73
|
+
log_path: Path
|
|
74
|
+
started_at: str
|
|
75
|
+
default_effort: str | None = None
|
|
76
|
+
max_allowed_effort: str | None = None
|
|
77
|
+
finished_at: str | None = None
|
|
78
|
+
final_status: str = "running"
|
|
79
|
+
error_message: str | None = None
|
|
80
|
+
attempts: dict[int, AttemptStats] = field(default_factory=dict)
|
|
81
|
+
counts: dict[str, int] = field(
|
|
82
|
+
default_factory=lambda: {
|
|
83
|
+
"attempts_started": 0,
|
|
84
|
+
"commits_created": 0,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def attempt_dir(self, attempt: int, retry: int = 1) -> Path:
|
|
89
|
+
if attempt < 1:
|
|
90
|
+
raise ValueError(f"attempt must be >= 1, got {attempt}")
|
|
91
|
+
if retry < 1:
|
|
92
|
+
raise ValueError(f"retry must be >= 1, got {retry}")
|
|
93
|
+
base = self.root / f"attempt-{attempt:03d}"
|
|
94
|
+
path = base if retry == 1 else base / f"retry-{retry:02d}"
|
|
95
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
return path
|
|
97
|
+
|
|
98
|
+
def baseline_dir(self, label: str) -> Path:
|
|
99
|
+
path = self.root / "baseline" / label
|
|
100
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
return path
|
|
102
|
+
|
|
103
|
+
def ensure_attempt(self, attempt: int) -> AttemptStats:
|
|
104
|
+
if attempt not in self.attempts:
|
|
105
|
+
self.attempts[attempt] = AttemptStats(attempt=attempt)
|
|
106
|
+
return self.attempts[attempt]
|
|
107
|
+
|
|
108
|
+
def update_attempt(
|
|
109
|
+
self,
|
|
110
|
+
attempt: int,
|
|
111
|
+
*,
|
|
112
|
+
target: str | None | object = _UNSET,
|
|
113
|
+
retry: int | None | object = _UNSET,
|
|
114
|
+
call_role: str | None | object = _UNSET,
|
|
115
|
+
phase_reached: str | None | object = _UNSET,
|
|
116
|
+
decision: str | None | object = _UNSET,
|
|
117
|
+
retry_recommendation: str | None | object = _UNSET,
|
|
118
|
+
failure_kind: str | None | object = _UNSET,
|
|
119
|
+
failure_summary: str | None | object = _UNSET,
|
|
120
|
+
reason_doc_path: Path | None | object = _UNSET,
|
|
121
|
+
effort: Mapping[str, object] | None | object = _UNSET,
|
|
122
|
+
) -> None:
|
|
123
|
+
stats = self.ensure_attempt(attempt)
|
|
124
|
+
if target is not _UNSET:
|
|
125
|
+
stats.target = target
|
|
126
|
+
if retry is not _UNSET:
|
|
127
|
+
stats.retry = retry
|
|
128
|
+
if call_role is not _UNSET:
|
|
129
|
+
stats.call_role = call_role
|
|
130
|
+
if phase_reached is not _UNSET:
|
|
131
|
+
stats.phase_reached = phase_reached
|
|
132
|
+
if decision is not _UNSET:
|
|
133
|
+
stats.decision = decision
|
|
134
|
+
if retry_recommendation is not _UNSET:
|
|
135
|
+
stats.retry_recommendation = retry_recommendation
|
|
136
|
+
if failure_kind is not _UNSET:
|
|
137
|
+
stats.failure_kind = failure_kind
|
|
138
|
+
if failure_summary is not _UNSET:
|
|
139
|
+
stats.failure_summary = failure_summary
|
|
140
|
+
if reason_doc_path is not _UNSET:
|
|
141
|
+
stats.reason_doc_path = (
|
|
142
|
+
str(reason_doc_path) if reason_doc_path is not None else None
|
|
143
|
+
)
|
|
144
|
+
if effort is not _UNSET:
|
|
145
|
+
_apply_effort_fields(stats, effort if effort is not None else None)
|
|
146
|
+
self.write_summary()
|
|
147
|
+
|
|
148
|
+
def log(self, level: str, message: str, **fields: object) -> None:
|
|
149
|
+
timestamp = iso_timestamp()
|
|
150
|
+
line = f"[{level}] {message}"
|
|
151
|
+
print(line)
|
|
152
|
+
with self.log_path.open("a", encoding="utf-8") as handle:
|
|
153
|
+
handle.write(f"[{timestamp}] {line}\n")
|
|
154
|
+
event = {"timestamp": timestamp, "level": level, "message": message, **fields}
|
|
155
|
+
_append_event(self.events_path, event)
|
|
156
|
+
self.write_summary()
|
|
157
|
+
|
|
158
|
+
def mark_attempt_started(self, attempt: int) -> None:
|
|
159
|
+
self.counts["attempts_started"] += 1
|
|
160
|
+
self.ensure_attempt(attempt)
|
|
161
|
+
self.write_summary()
|
|
162
|
+
|
|
163
|
+
def log_call_started(
|
|
164
|
+
self,
|
|
165
|
+
*,
|
|
166
|
+
attempt: int,
|
|
167
|
+
retry: int,
|
|
168
|
+
target: str,
|
|
169
|
+
display_target: str | None = None,
|
|
170
|
+
call_role: str,
|
|
171
|
+
phase_reached: str | None = None,
|
|
172
|
+
effort: Mapping[str, object] | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.update_attempt(
|
|
175
|
+
attempt,
|
|
176
|
+
target=target,
|
|
177
|
+
retry=retry,
|
|
178
|
+
call_role=call_role,
|
|
179
|
+
phase_reached=phase_reached or call_role,
|
|
180
|
+
effort=effort,
|
|
181
|
+
)
|
|
182
|
+
effort_fields = dict(effort or {})
|
|
183
|
+
human_target = display_target or target
|
|
184
|
+
self.log(
|
|
185
|
+
"INFO",
|
|
186
|
+
f"call start: {call_role} — {human_target}{_effort_log_suffix(effort)}",
|
|
187
|
+
event="call_started",
|
|
188
|
+
attempt=attempt,
|
|
189
|
+
retry=retry,
|
|
190
|
+
target=target,
|
|
191
|
+
call_role=call_role,
|
|
192
|
+
phase_reached=phase_reached or call_role,
|
|
193
|
+
**effort_fields,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def log_call_finished(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
attempt: int,
|
|
200
|
+
retry: int,
|
|
201
|
+
target: str,
|
|
202
|
+
call_role: str,
|
|
203
|
+
display_target: str | None = None,
|
|
204
|
+
phase_reached: str | None = None,
|
|
205
|
+
status: str,
|
|
206
|
+
level: str = "INFO",
|
|
207
|
+
returncode: int | None = None,
|
|
208
|
+
summary: str | None = None,
|
|
209
|
+
effort: Mapping[str, object] | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
self.update_attempt(
|
|
212
|
+
attempt,
|
|
213
|
+
target=target,
|
|
214
|
+
retry=retry,
|
|
215
|
+
call_role=call_role,
|
|
216
|
+
phase_reached=phase_reached or call_role,
|
|
217
|
+
failure_summary=summary,
|
|
218
|
+
effort=effort,
|
|
219
|
+
)
|
|
220
|
+
effort_fields = dict(effort or {})
|
|
221
|
+
human_target = display_target or target
|
|
222
|
+
self.log(
|
|
223
|
+
level,
|
|
224
|
+
f"call {status}: {call_role} — {human_target}"
|
|
225
|
+
f"{_effort_log_suffix(effort)}",
|
|
226
|
+
event="call_finished",
|
|
227
|
+
attempt=attempt,
|
|
228
|
+
retry=retry,
|
|
229
|
+
target=target,
|
|
230
|
+
call_role=call_role,
|
|
231
|
+
phase_reached=phase_reached or call_role,
|
|
232
|
+
call_status=status,
|
|
233
|
+
returncode=returncode,
|
|
234
|
+
summary=summary,
|
|
235
|
+
**effort_fields,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def log_transition(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
attempt: int,
|
|
242
|
+
retry: int,
|
|
243
|
+
target: str,
|
|
244
|
+
call_role: str,
|
|
245
|
+
phase_reached: str,
|
|
246
|
+
decision: str,
|
|
247
|
+
retry_recommendation: str,
|
|
248
|
+
failure_kind: str,
|
|
249
|
+
summary: str,
|
|
250
|
+
reason_doc_path: Path | None,
|
|
251
|
+
) -> None:
|
|
252
|
+
self.update_attempt(
|
|
253
|
+
attempt,
|
|
254
|
+
target=target,
|
|
255
|
+
retry=retry,
|
|
256
|
+
call_role=call_role,
|
|
257
|
+
phase_reached=phase_reached,
|
|
258
|
+
decision=decision,
|
|
259
|
+
retry_recommendation=retry_recommendation,
|
|
260
|
+
failure_kind=failure_kind,
|
|
261
|
+
failure_summary=summary,
|
|
262
|
+
reason_doc_path=reason_doc_path,
|
|
263
|
+
)
|
|
264
|
+
self.log(
|
|
265
|
+
"WARN",
|
|
266
|
+
f"target transition: {decision}/{retry_recommendation} — {target}",
|
|
267
|
+
event="target_transition",
|
|
268
|
+
attempt=attempt,
|
|
269
|
+
retry=retry,
|
|
270
|
+
target=target,
|
|
271
|
+
call_role=call_role,
|
|
272
|
+
phase_reached=phase_reached,
|
|
273
|
+
decision=decision,
|
|
274
|
+
retry_recommendation=retry_recommendation,
|
|
275
|
+
failure_kind=failure_kind,
|
|
276
|
+
summary=summary,
|
|
277
|
+
reason_doc_path=str(reason_doc_path) if reason_doc_path else None,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def record_commit(self, attempt: int, phase: str, commit_sha: str) -> None:
|
|
281
|
+
stats = self.ensure_attempt(attempt)
|
|
282
|
+
stats.commit_sha = commit_sha
|
|
283
|
+
stats.commit_phase = phase
|
|
284
|
+
self.counts["commits_created"] += 1
|
|
285
|
+
self.write_summary()
|
|
286
|
+
|
|
287
|
+
def finish(self, status: str, error_message: str | None = None) -> None:
|
|
288
|
+
self.finished_at = iso_timestamp()
|
|
289
|
+
self.final_status = status
|
|
290
|
+
self.error_message = error_message
|
|
291
|
+
self.write_summary()
|
|
292
|
+
|
|
293
|
+
def write_summary(self) -> None:
|
|
294
|
+
summary = {
|
|
295
|
+
"run_id": self.run_id,
|
|
296
|
+
"artifact_root": str(self.root),
|
|
297
|
+
"repo_root": str(self.repo_root),
|
|
298
|
+
"agent": self.agent,
|
|
299
|
+
"model": self.model,
|
|
300
|
+
"effort": self.effort,
|
|
301
|
+
"default_effort": self.default_effort or self.effort,
|
|
302
|
+
"max_allowed_effort": self.max_allowed_effort or self.effort,
|
|
303
|
+
"test_command": self.test_command,
|
|
304
|
+
"started_at": self.started_at,
|
|
305
|
+
"finished_at": self.finished_at,
|
|
306
|
+
"final_status": self.final_status,
|
|
307
|
+
"error_message": self.error_message,
|
|
308
|
+
"counts": self.counts,
|
|
309
|
+
"attempts": [asdict(self.attempts[key]) for key in sorted(self.attempts)],
|
|
310
|
+
}
|
|
311
|
+
content = _serialize_summary(self.summary_path, summary)
|
|
312
|
+
_write_text_atomic(self.summary_path, content)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def iso_timestamp() -> str:
|
|
316
|
+
return _now().isoformat(timespec="milliseconds")
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _now() -> datetime:
|
|
320
|
+
return datetime.now().astimezone()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def default_artifacts_root() -> Path:
|
|
324
|
+
return Path(os.environ.get("TMPDIR") or tempfile.gettempdir())
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _append_event(path: Path, event: Mapping[str, object]) -> None:
|
|
328
|
+
try:
|
|
329
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
330
|
+
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
|
331
|
+
except OSError as exc:
|
|
332
|
+
raise ContinuousRefactorError(f"Could not append artifact event to {path}") from exc
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _serialize_summary(path: Path, summary: Mapping[str, object]) -> str:
|
|
336
|
+
try:
|
|
337
|
+
return json.dumps(summary, indent=2, sort_keys=True) + "\n"
|
|
338
|
+
except (TypeError, ValueError) as exc:
|
|
339
|
+
raise ContinuousRefactorError(f"Could not serialize run summary for {path}") from exc
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _write_text_atomic(path: Path, content: str) -> None:
|
|
343
|
+
tmp_path: Path | None = None
|
|
344
|
+
try:
|
|
345
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
with tempfile.NamedTemporaryFile(
|
|
347
|
+
mode="w", encoding="utf-8", dir=path.parent, suffix=".tmp", delete=False
|
|
348
|
+
) as tmp:
|
|
349
|
+
tmp_path = Path(tmp.name)
|
|
350
|
+
tmp.write(content)
|
|
351
|
+
os.replace(tmp_path, path)
|
|
352
|
+
except OSError as exc:
|
|
353
|
+
if tmp_path is not None:
|
|
354
|
+
tmp_path.unlink(missing_ok=True)
|
|
355
|
+
raise ContinuousRefactorError(f"Could not write artifact file {path}") from exc
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def create_run_artifacts(
|
|
359
|
+
repo_root: Path,
|
|
360
|
+
*,
|
|
361
|
+
agent: str,
|
|
362
|
+
model: str,
|
|
363
|
+
effort: str,
|
|
364
|
+
default_effort: str | None = None,
|
|
365
|
+
max_allowed_effort: str | None = None,
|
|
366
|
+
test_command: str,
|
|
367
|
+
) -> RunArtifacts:
|
|
368
|
+
started_at_dt = _now()
|
|
369
|
+
started_at = started_at_dt.isoformat(timespec="milliseconds")
|
|
370
|
+
run_id = started_at_dt.strftime("%Y%m%dT%H%M%S-%f")
|
|
371
|
+
root = default_artifacts_root() / "continuous-refactoring" / run_id
|
|
372
|
+
root.mkdir(parents=True, exist_ok=False)
|
|
373
|
+
artifacts = RunArtifacts(
|
|
374
|
+
root=root,
|
|
375
|
+
run_id=run_id,
|
|
376
|
+
repo_root=repo_root,
|
|
377
|
+
agent=agent,
|
|
378
|
+
model=model,
|
|
379
|
+
effort=effort,
|
|
380
|
+
default_effort=default_effort or effort,
|
|
381
|
+
max_allowed_effort=max_allowed_effort or effort,
|
|
382
|
+
test_command=test_command,
|
|
383
|
+
events_path=root / "events.jsonl",
|
|
384
|
+
summary_path=root / "summary.json",
|
|
385
|
+
log_path=root / "run.log",
|
|
386
|
+
started_at=started_at,
|
|
387
|
+
)
|
|
388
|
+
artifacts.write_summary()
|
|
389
|
+
return artifacts
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _apply_effort_fields(
|
|
393
|
+
stats: AttemptStats,
|
|
394
|
+
effort: Mapping[str, object] | None,
|
|
395
|
+
) -> None:
|
|
396
|
+
if effort is None:
|
|
397
|
+
return
|
|
398
|
+
fields = {
|
|
399
|
+
"requested_effort",
|
|
400
|
+
"effective_effort",
|
|
401
|
+
"max_allowed_effort",
|
|
402
|
+
"effort_source",
|
|
403
|
+
"effort_capped",
|
|
404
|
+
"effort_reason",
|
|
405
|
+
}
|
|
406
|
+
for field_name in fields:
|
|
407
|
+
if field_name not in effort:
|
|
408
|
+
continue
|
|
409
|
+
value = effort[field_name]
|
|
410
|
+
setattr(stats, field_name, value if value is not None else None)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _effort_log_suffix(effort: Mapping[str, object] | None) -> str:
|
|
414
|
+
if not effort:
|
|
415
|
+
return ""
|
|
416
|
+
effective = _string_field(effort.get("effective_effort"))
|
|
417
|
+
requested = _string_field(effort.get("requested_effort"))
|
|
418
|
+
selected = effective or requested
|
|
419
|
+
if selected is None:
|
|
420
|
+
return ""
|
|
421
|
+
parts = [f"effort={selected}"]
|
|
422
|
+
if effective is not None and requested is not None and requested != effective:
|
|
423
|
+
parts.append(f"requested={requested}")
|
|
424
|
+
return f" ({' '.join(parts)})"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _string_field(value: object) -> str | None:
|
|
428
|
+
if value is None:
|
|
429
|
+
return None
|
|
430
|
+
text = str(value).strip()
|
|
431
|
+
return text or None
|