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.
@@ -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"