quality-loop 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,110 @@
1
+ """Screens on Windows: Edge for pages, Windows Terminal for scripts.
2
+
3
+ A window is recognised the same way as on macOS, by its title: the top-level
4
+ windows that appear after the launch are enumerated, and the one whose title
5
+ contains the declared one is the screen. Its handle is recorded, and closing
6
+ posts WM_CLOSE to that handle and nothing else.
7
+
8
+ Every ctypes prototype is declared explicitly: on 64-bit Windows the default
9
+ c_int restype truncates handles.
10
+
11
+ Not yet confirmed on a Windows machine: that Windows Terminal keeps the title
12
+ with --suppressApplicationTitle, that Edge's window title contains the page
13
+ title, and that --new-window gives a window rather than a tab.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import subprocess
19
+ import time
20
+
21
+ from quality_loop.screens import ScreenError
22
+
23
+ WM_CLOSE = 0x0010
24
+
25
+
26
+ def _user32():
27
+ import ctypes
28
+ from ctypes import wintypes
29
+
30
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
31
+ enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
32
+ user32.EnumWindows.argtypes = [enum_proc, wintypes.LPARAM]
33
+ user32.EnumWindows.restype = wintypes.BOOL
34
+ user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
35
+ user32.GetWindowTextLengthW.restype = ctypes.c_int
36
+ user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
37
+ user32.GetWindowTextW.restype = ctypes.c_int
38
+ user32.IsWindowVisible.argtypes = [wintypes.HWND]
39
+ user32.IsWindowVisible.restype = wintypes.BOOL
40
+ user32.IsWindow.argtypes = [wintypes.HWND]
41
+ user32.IsWindow.restype = wintypes.BOOL
42
+ user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT,
43
+ wintypes.WPARAM, wintypes.LPARAM]
44
+ user32.PostMessageW.restype = wintypes.BOOL
45
+ return ctypes, user32, enum_proc
46
+
47
+
48
+ def windows() -> dict[int, str]:
49
+ """``{handle: title}`` of the visible top-level windows."""
50
+ ctypes, user32, enum_proc = _user32()
51
+ found: dict[int, str] = {}
52
+
53
+ def each(hwnd, _lparam):
54
+ if user32.IsWindowVisible(hwnd):
55
+ length = user32.GetWindowTextLengthW(hwnd)
56
+ buffer = ctypes.create_unicode_buffer(length + 1)
57
+ user32.GetWindowTextW(hwnd, buffer, length + 1)
58
+ found[int(hwnd)] = buffer.value
59
+ return True
60
+
61
+ user32.EnumWindows(enum_proc(each), 0)
62
+ return found
63
+
64
+
65
+ def page_command(url: str) -> list[str]:
66
+ return ["cmd", "/c", "start", "", "msedge", "--new-window", url]
67
+
68
+
69
+ #: The shell a terminal screen runs in: PowerShell without the operator's
70
+ #: profile, and kept open once the script ends. Given the script directly,
71
+ #: Windows Terminal closes the tab when it exits, and the room has nothing to read.
72
+ SHELL = ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command"]
73
+
74
+
75
+ def _powershell_quote(value: str) -> str:
76
+ return "'" + value.replace("'", "''") + "'"
77
+
78
+
79
+ def terminal_command(command: list[str], title: str) -> list[str]:
80
+ run = "& " + " ".join(_powershell_quote(part) for part in command)
81
+ return ["wt.exe", "-w", "new", "--title", title, "--suppressApplicationTitle",
82
+ *SHELL, run]
83
+
84
+
85
+ def _launch_and_find(launch: list[str], title: str, patience: float,
86
+ listing=windows, run=subprocess.Popen) -> int:
87
+ before = set(listing())
88
+ run(launch, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
89
+ stderr=subprocess.DEVNULL)
90
+ deadline = time.monotonic() + patience
91
+ while time.monotonic() < deadline:
92
+ for handle, shown in listing().items():
93
+ if handle not in before and title in shown:
94
+ return handle
95
+ time.sleep(0.5)
96
+ raise ScreenError(f"no new window titled {title!r} appeared within {patience:.0f}s")
97
+
98
+
99
+ def open_page(url: str, title: str, patience: float) -> int:
100
+ return _launch_and_find(page_command(url), title, patience)
101
+
102
+
103
+ def open_terminal(command: list[str], title: str, patience: float) -> int:
104
+ return _launch_and_find(terminal_command(command, title), title, patience)
105
+
106
+
107
+ def close(kind: str, handle: int) -> None:
108
+ _ctypes, user32, _proc = _user32()
109
+ if user32.IsWindow(handle):
110
+ user32.PostMessageW(handle, WM_CLOSE, 0, 0)
@@ -0,0 +1,301 @@
1
+ """Starting a room's session, and the flags that make it a clean one.
2
+
3
+ The room is driven non-interactively on purpose. "Answer nothing it asks" is a
4
+ discipline, and a discipline fails at the first plausible-sounding question; a
5
+ `-p` session has nobody to ask, so the only questions it can raise are the
6
+ ones it writes into QUESTIONS.md - which is what the exercise produces.
7
+
8
+ Every flag here was paid for by a run:
9
+
10
+ - `--restricted`: a session in a room inside the checkout, asked what project
11
+ it is in and allowed to read nothing, names the product without it and
12
+ answers UNKNOWN with it. It also hides the checkout's own skills, the
13
+ scoring key among them, and ignores the settings files.
14
+ - `--strict-mcp-config` with exactly the servers given: whatever else the
15
+ operator has connected is not part of the product's interface.
16
+ - `--settings` with the permission list, under `--permission-mode dontAsk`:
17
+ a `-p` session with no grant is refused every tool call, and a first run
18
+ reported, accurately and uselessly, that it could not write a file.
19
+ - `--no-session-persistence`: the transcript is kept in the room.
20
+ - `GIT_CEILING_DIRECTORIES` at the room's parent, so `git log` from the room
21
+ answers "not a git repository" rather than reading the checkout's history,
22
+ whose commit titles say what every change to the product was for.
23
+ - stdin closed: a `-p` session with an open one waits three seconds for
24
+ piped input and writes a warning to the top of the transcript.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import dataclasses
30
+ import json
31
+ import os
32
+ import pathlib
33
+ import shlex
34
+ import shutil
35
+ import subprocess
36
+ import tempfile
37
+ import threading
38
+ import time
39
+ from typing import Callable
40
+
41
+ #: The sentence a room is opened with, and the whole of what it is told.
42
+ PROMPT = "Read RULES.md, then do TASK.md."
43
+
44
+
45
+ def find_claude() -> str:
46
+ """The `claude` binary, which is routinely not on PATH.
47
+
48
+ It often lives inside the VS Code extension only, so a script that just
49
+ calls "claude" fails at the last step of a run that has already built a
50
+ room. ``CLAUDE_BIN`` overrides the search.
51
+ """
52
+ if override := os.environ.get("CLAUDE_BIN"):
53
+ return override
54
+ if found := shutil.which("claude"):
55
+ return found
56
+ binary = "claude.exe" if os.name == "nt" else "claude"
57
+ candidates = list(pathlib.Path.home().glob(
58
+ f".vscode/extensions/anthropic.claude-code-*/resources/native-binary/{binary}"))
59
+ local = pathlib.Path.home() / ".claude" / "local" / binary
60
+ if local.exists():
61
+ candidates.append(local)
62
+ if not candidates:
63
+ raise FileNotFoundError(
64
+ "no `claude` binary - put it on PATH or set CLAUDE_BIN=/path/to/claude")
65
+ # Newest install wins; version strings sort wrong (2.1.99 > 2.1.251).
66
+ return str(max(candidates, key=lambda p: p.stat().st_mtime))
67
+
68
+
69
+ @dataclasses.dataclass
70
+ class Session:
71
+ """How a product's rooms are started.
72
+
73
+ Attributes:
74
+ allow: The permission list - the room's job description. What is
75
+ listed runs; everything else is refused.
76
+ tools: The built-in tools the session has at all (``--tools``), or
77
+ None for `--restricted`'s default, which has no Bash. A product
78
+ whose surface is a CLI grants a narrowed Bash here and in
79
+ ``allow``.
80
+ add_dirs: Directories outside the room the file tools may reach.
81
+ mcp_servers: ``{name: server config}`` - exactly these, nothing else.
82
+ env: Extra environment for the session (a PATH shim, a shell).
83
+ max_turns: Turn limit for a non-interactive run.
84
+ model: Model override, or None for the default.
85
+ """
86
+ allow: list[str]
87
+ tools: list[str] | None = None
88
+ add_dirs: list[pathlib.Path] = dataclasses.field(default_factory=list)
89
+ mcp_servers: dict = dataclasses.field(default_factory=dict)
90
+ env: dict = dataclasses.field(default_factory=dict)
91
+ max_turns: int = 150
92
+ model: str | None = None
93
+ claude: str | None = None
94
+
95
+ def command(self, interactive: bool = False) -> list[str]:
96
+ """The command line, without ``-p`` when a person will drive it."""
97
+ command = [self.claude or find_claude(), "--restricted"]
98
+ if self.tools is not None:
99
+ command += ["--tools", ",".join(self.tools)]
100
+ for directory in self.add_dirs:
101
+ command += ["--add-dir", str(directory)]
102
+ command += [
103
+ "--mcp-config", json.dumps({"mcpServers": self.mcp_servers}),
104
+ "--strict-mcp-config",
105
+ # --restricted ignores the settings files, so the permissions have
106
+ # to be handed over directly.
107
+ "--settings", json.dumps({"permissions": {"allow": self.allow}}),
108
+ "--permission-mode", "dontAsk",
109
+ ]
110
+ if interactive:
111
+ return command
112
+ command += ["-p", PROMPT, "--no-session-persistence",
113
+ "--max-turns", str(self.max_turns),
114
+ "--output-format", "stream-json", "--verbose"]
115
+ if self.model:
116
+ command += ["--model", self.model]
117
+ return command
118
+
119
+ def environment(self, room: pathlib.Path) -> dict:
120
+ """The operator's environment, with git stopped at the room."""
121
+ return dict(os.environ, **self.env,
122
+ GIT_CEILING_DIRECTORIES=str(pathlib.Path(room).resolve().parent))
123
+
124
+ def printable(self, room: pathlib.Path, shell: str | None = None) -> str:
125
+ """The same session, for a person to paste - flags and all.
126
+
127
+ A bare `cd <room> && claude` is how a room in the checkout gets opened
128
+ without `--restricted`, which is the failure the whole arrangement
129
+ trades on not happening. ``shell`` is "posix" or "powershell"; the
130
+ default is this OS's.
131
+ """
132
+ shell = shell or ("powershell" if os.name == "nt" else "posix")
133
+ variables = {**self.env,
134
+ "GIT_CEILING_DIRECTORIES": str(pathlib.Path(room).parent)}
135
+ command = self.command(interactive=True)
136
+ if shell == "powershell":
137
+ quote = _powershell_quote
138
+ lines = [f"Set-Location {quote(str(room))}"]
139
+ lines += [f"$env:{name} = {quote(str(value))}"
140
+ for name, value in variables.items()]
141
+ lines.append("& " + " `\n ".join(_paired(command, quote)))
142
+ return "\n".join(lines)
143
+ quote = shlex.quote
144
+ assignments = [f"{name}={quote(str(value))}" for name, value in variables.items()]
145
+ return (f"cd {quote(str(room))} && \\\n "
146
+ + " \\\n ".join(assignments + _paired(command, quote)))
147
+
148
+ def run(self, room: pathlib.Path, on_event: Callable[[dict], None] | None = None,
149
+ alive: Callable[[], bool] | None = None, heartbeat: float = 20.0,
150
+ say: Callable[[str], None] = print) -> "Run":
151
+ """Drive the room to completion, keeping the transcript in it.
152
+
153
+ The transcript is written outside the room and moved in at the end:
154
+ left in the room it is a file the room can read, and one room did -
155
+ grepping its own log to reconstruct the task its prompt referred to.
156
+
157
+ ``alive`` is asked on the heartbeat whether the product is still
158
+ answering. Two misses in a row end the run: a room that cannot reach
159
+ the product measures the outage, and one went on for twenty-three
160
+ minutes after its product had died.
161
+ """
162
+ room = pathlib.Path(room)
163
+ transcript = (pathlib.Path(tempfile.gettempdir())
164
+ / f"quality-loop-{room.parent.name}-{room.name}.jsonl")
165
+ result = Run(room / "transcript.jsonl")
166
+ clock = _Clock()
167
+ try:
168
+ with subprocess.Popen(self.command(), cwd=room, stdin=subprocess.DEVNULL,
169
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
170
+ text=True, encoding="utf-8", bufsize=1,
171
+ env=self.environment(room)) as session, \
172
+ transcript.open("w", encoding="utf-8") as log:
173
+ beat = threading.Thread(target=_heartbeat, daemon=True,
174
+ args=(session, clock, alive, heartbeat, say, result))
175
+ beat.start()
176
+ try:
177
+ for line in session.stdout:
178
+ log.write(line)
179
+ clock.touch()
180
+ try:
181
+ event = json.loads(line)
182
+ except ValueError:
183
+ continue
184
+ if isinstance(event, dict):
185
+ if on_event:
186
+ on_event(event)
187
+ if text := digest(event, room, clock):
188
+ say(text)
189
+ except BaseException:
190
+ # Ctrl-C or SIGTERM: the session goes with the run, so
191
+ # nothing is still writing into what is being collected.
192
+ session.terminate()
193
+ raise
194
+ result.exit_code = session.wait()
195
+ finally:
196
+ if transcript.exists():
197
+ shutil.move(transcript, result.transcript)
198
+ return result
199
+
200
+
201
+ @dataclasses.dataclass
202
+ class Run:
203
+ """What driving a room came to."""
204
+ transcript: pathlib.Path
205
+ exit_code: int | None = None
206
+ product_lost: bool = False
207
+
208
+
209
+ def _paired(command: list[str], quote) -> list[str]:
210
+ """Each flag on one line with its value."""
211
+ parts = [quote(part) for part in command]
212
+ lines, index = [], 0
213
+ while index < len(parts):
214
+ flag, value = command[index], parts[index + 1:index + 2]
215
+ if flag.startswith("--") and value and not command[index + 1].startswith("--"):
216
+ lines.append(f"{parts[index]} {value[0]}")
217
+ index += 2
218
+ else:
219
+ lines.append(parts[index])
220
+ index += 1
221
+ return lines
222
+
223
+
224
+ def _powershell_quote(value: str) -> str:
225
+ return "'" + value.replace("'", "''") + "'"
226
+
227
+
228
+ class _Clock:
229
+ def __init__(self):
230
+ self.started = self.last = time.monotonic()
231
+
232
+ def touch(self):
233
+ self.last = time.monotonic()
234
+
235
+ def __str__(self):
236
+ seconds = int(time.monotonic() - self.started)
237
+ return f"{seconds // 60:>2}:{seconds % 60:02d}"
238
+
239
+
240
+ def _heartbeat(session, clock, alive, every, say, result) -> None:
241
+ """Say the run is alive while it thinks, and end it when the product is not.
242
+
243
+ A model between tool calls writes nothing, so a long turn and a hung
244
+ process look identical; a line every quiet ``every`` seconds tells them
245
+ apart.
246
+ """
247
+ missed = 0
248
+ while session.poll() is None:
249
+ time.sleep(every / 4)
250
+ quiet = time.monotonic() - clock.last
251
+ if quiet < every:
252
+ continue
253
+ say(f" [{clock}] … thinking ({int(quiet)}s since the last step)")
254
+ clock.touch()
255
+ if alive is None:
256
+ continue
257
+ missed = 0 if alive() else missed + 1
258
+ if missed >= 2:
259
+ say(f" [{clock}] !! the product stopped answering - ending the run")
260
+ result.product_lost = True
261
+ session.terminate()
262
+ return
263
+
264
+
265
+ def _hint(given: dict, room: pathlib.Path) -> str:
266
+ """The part of a tool call worth one line: a path's tail, or a pattern."""
267
+ for key in ("file_path", "path", "notebook_path"):
268
+ if value := given.get(key):
269
+ shown = str(value)
270
+ return shown[len(str(room)) + 1:] if shown.startswith(str(room)) else shown
271
+ for key in ("pattern", "command", "app", "action", "title", "role", "text", "name"):
272
+ if value := given.get(key):
273
+ return str(value)[:70]
274
+ return ""
275
+
276
+
277
+ def digest(event: dict, room: pathlib.Path, clock="") -> str | None:
278
+ """One line per thing the room did, so a long run is watchable."""
279
+ from quality_loop.transcript import parts
280
+
281
+ lines = []
282
+ kind = event.get("type")
283
+ if kind == "assistant":
284
+ for part in parts(event):
285
+ if part.get("type") == "tool_use":
286
+ given = part.get("input") if isinstance(part.get("input"), dict) else {}
287
+ lines.append(f" [{clock}] · {part.get('name')} {_hint(given, room)}".rstrip())
288
+ elif part.get("type") == "text" and str(part.get("text", "")).strip():
289
+ lines.append(f" [{clock}] {str(part['text']).strip().splitlines()[0][:110]}")
290
+ elif kind == "user":
291
+ # A refusal is the one result worth a line: it is what a wrong
292
+ # permission list or a reach outside the room looks like from here.
293
+ for part in parts(event):
294
+ if part.get("type") == "tool_result" and part.get("is_error"):
295
+ said = str(part.get("content", "")).replace("\n", " ")
296
+ lines.append(f" [{clock}] ! refused - {said[:120]}")
297
+ elif kind == "result":
298
+ lines.append(f"\n turns: {event.get('num_turns', '?')}, "
299
+ f"{(event.get('duration_ms') or 0) / 1000:.0f}s, "
300
+ f"${event.get('total_cost_usd') or 0:.2f}")
301
+ return "\n".join(lines) or None
@@ -0,0 +1,193 @@
1
+ """Standing fixtures in for the operator's own files, and putting them back.
2
+
3
+ A task that edits the product's configuration has to edit a known file, and
4
+ must not edit the operator's. So for the length of a room their files are
5
+ moved into a marker directory, fixtures take their place, and everything is
6
+ put back on the way out - whatever ends the run. An exception, Ctrl-C and
7
+ SIGTERM (turned into KeyboardInterrupt by the runner) all unwind through the
8
+ same ``finally``. A marker left by a run that could not unwind refuses the
9
+ next one until `restore` has put it back.
10
+
11
+ Everything new in the directories the room can write to moves into the room's
12
+ evidence on the way out, not only the files it was asked for: a state file, a
13
+ helper package, a backup. Left behind, the next room finds them, and a product
14
+ that loads every module in its extensions directory shows them to the user.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import os
21
+ import pathlib
22
+ import shutil
23
+ from typing import Callable
24
+
25
+
26
+ class StandInError(Exception):
27
+ """The run cannot start without risking the operator's files."""
28
+
29
+
30
+ @dataclasses.dataclass
31
+ class Watch:
32
+ """A directory the room can write to.
33
+
34
+ Attributes:
35
+ directory: The directory.
36
+ label: Where its new entries go under the evidence directory.
37
+ move: Move new entries into evidence (True), or copy them and leave
38
+ them (False - for the operator who asked to keep what a run made).
39
+ skip: Name prefixes that are never collected: the product's own state
40
+ beside the files, which moving would break (an endpoint file, a
41
+ settings file).
42
+ """
43
+ directory: pathlib.Path
44
+ label: str
45
+ move: bool = True
46
+ skip: tuple[str, ...] = ()
47
+
48
+
49
+ def _entries(directory: pathlib.Path) -> set[str]:
50
+ return {p.name for p in directory.iterdir()} if directory.is_dir() else set()
51
+
52
+
53
+ @dataclasses.dataclass
54
+ class StandIn:
55
+ """The operator's machine made into the room's starting state, and given back.
56
+
57
+ Attributes:
58
+ home: The product's data directory, where the stood-in files live.
59
+ marker: Where the operator's originals wait. Its existence at start
60
+ means a run ended without putting them back.
61
+ fixtures: ``{file name in home: the fixture's text}``.
62
+ evidence: The room's evidence directory.
63
+ backups: A glob, in ``home``, of backups the product keeps of those
64
+ files and prunes. Copies wait in the marker, and any the room's
65
+ writes pruned come back.
66
+ watch: Directories whose new entries are collected.
67
+ staged: ``(source, destination)`` input files a task works from. A
68
+ destination that already exists refuses the run rather than being
69
+ overwritten.
70
+ on_change: Called after the fixtures go in and after the originals
71
+ come back - a reload, so the running product follows the file.
72
+ What it returns is reported if it says something went wrong.
73
+ """
74
+ home: pathlib.Path
75
+ marker: pathlib.Path
76
+ fixtures: dict[str, str]
77
+ evidence: pathlib.Path
78
+ backups: str | None = None
79
+ watch: list[Watch] = dataclasses.field(default_factory=list)
80
+ staged: list[tuple[pathlib.Path, pathlib.Path]] = dataclasses.field(default_factory=list)
81
+ on_change: Callable[[], object] | None = None
82
+
83
+ def __enter__(self) -> "StandIn":
84
+ if self.marker.exists():
85
+ raise StandInError(
86
+ f"{self.marker} exists: a run ended without putting the operator's "
87
+ f"files back. Check that what is in it is theirs, then restore it.")
88
+ for _source, destination in self.staged:
89
+ if destination.exists():
90
+ raise StandInError(
91
+ f"{destination} already exists, and this run puts its own file "
92
+ f"there. Move it out of the way first.")
93
+ self._before = {str(w.directory): _entries(w.directory) for w in self.watch}
94
+ self.marker.mkdir(parents=True)
95
+ self._had = {}
96
+ for name, text in self.fixtures.items():
97
+ own = self.home / name
98
+ self._had[name] = own.exists()
99
+ if own.exists():
100
+ os.replace(own, self.marker / name)
101
+ # LF on every OS: a fixture compared line by line with what the
102
+ # room left must not differ in its line ends alone.
103
+ own.write_text(text, encoding="utf-8", newline="\n")
104
+ if self.backups:
105
+ for backup in self.home.glob(self.backups):
106
+ shutil.copy2(backup, self.marker / backup.name)
107
+ for source, destination in self.staged:
108
+ shutil.copy2(source, destination)
109
+ if self.on_change:
110
+ self.on_change()
111
+ return self
112
+
113
+ def __exit__(self, *exc) -> bool:
114
+ try:
115
+ self.collect()
116
+ finally:
117
+ self.give_back()
118
+ return False
119
+
120
+ def collect(self) -> None:
121
+ """Move everything the run made into the evidence."""
122
+ before_dir, after_dir = self.evidence / "before", self.evidence / "after"
123
+ before_dir.mkdir(parents=True, exist_ok=True)
124
+ after_dir.mkdir(parents=True, exist_ok=True)
125
+ for name, text in self.fixtures.items():
126
+ (before_dir / name).write_text(text, encoding="utf-8", newline="\n")
127
+ if (self.home / name).exists():
128
+ shutil.copy2(self.home / name, after_dir / name)
129
+ for watch in self.watch:
130
+ before = self._before.get(str(watch.directory), set())
131
+ for entry in sorted(_entries(watch.directory) - before):
132
+ if watch.directory == self.home and (
133
+ entry in self.fixtures or entry == self.marker.name):
134
+ continue
135
+ if watch.skip and entry.startswith(watch.skip):
136
+ continue
137
+ if entry == "__pycache__":
138
+ shutil.rmtree(watch.directory / entry, ignore_errors=True)
139
+ continue
140
+ source = watch.directory / entry
141
+ target = self.evidence / watch.label / entry
142
+ target.parent.mkdir(parents=True, exist_ok=True)
143
+ if watch.move:
144
+ shutil.move(str(source), target)
145
+ elif source.is_dir():
146
+ shutil.copytree(source, target)
147
+ else:
148
+ shutil.copy2(source, target)
149
+
150
+ def give_back(self) -> None:
151
+ """Put the operator's files back."""
152
+ for name, had in self._had.items():
153
+ if had:
154
+ os.replace(self.marker / name, self.home / name)
155
+ else:
156
+ (self.home / name).unlink(missing_ok=True)
157
+ _return_backups(self.marker, self.home)
158
+ shutil.rmtree(self.marker, ignore_errors=True)
159
+ if self.on_change:
160
+ self.on_change()
161
+
162
+
163
+ def _return_backups(marker: pathlib.Path, home: pathlib.Path) -> None:
164
+ for kept in marker.iterdir() if marker.is_dir() else ():
165
+ if kept.is_file() and not (home / kept.name).exists():
166
+ os.replace(kept, home / kept.name)
167
+
168
+
169
+ def restore(marker: pathlib.Path, home: pathlib.Path, names: list[str],
170
+ on_change: Callable[[], object] | None = None) -> list[str]:
171
+ """Put back what a run that could not unwind left in ``marker``.
172
+
173
+ A fixture still in place is moved aside to ``<name>.quality-loop-fixture``
174
+ rather than deleted. Returns what it did, one line each.
175
+ """
176
+ done = []
177
+ if not marker.exists():
178
+ return ["nothing to restore"]
179
+ for name in names:
180
+ saved = marker / name
181
+ if not saved.exists():
182
+ continue
183
+ if (home / name).exists():
184
+ aside = home / f"{name}.quality-loop-fixture"
185
+ os.replace(home / name, aside)
186
+ done.append(f"moved the fixture aside to {aside}")
187
+ os.replace(saved, home / name)
188
+ done.append(f"restored {home / name}")
189
+ _return_backups(marker, home)
190
+ shutil.rmtree(marker)
191
+ if on_change:
192
+ on_change()
193
+ return done