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,733 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import signal
6
+ import shlex
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass, replace
12
+ from pathlib import Path
13
+ from shutil import which
14
+ from typing import TYPE_CHECKING, TextIO
15
+
16
+ try:
17
+ import termios
18
+ except ImportError: # pragma: no cover - termios is unavailable on Windows.
19
+ termios = None
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Sequence
23
+
24
+ __all__ = [
25
+ "build_command",
26
+ "maybe_run_agent",
27
+ "run_agent_interactive",
28
+ "run_agent_interactive_until_settled",
29
+ "run_observed_command",
30
+ "run_tests",
31
+ "summarize_output",
32
+ ]
33
+
34
+ from continuous_refactoring.artifacts import (
35
+ CommandCapture,
36
+ ContinuousRefactorError,
37
+ iso_timestamp,
38
+ )
39
+
40
+
41
+ def _require_supported_agent(agent: str) -> None:
42
+ if agent not in {"codex", "claude"}:
43
+ raise ContinuousRefactorError(f"Unsupported agent backend: {agent}")
44
+
45
+
46
+ def _build_claude_command(
47
+ model: str,
48
+ effort: str,
49
+ prompt: str,
50
+ ) -> list[str]:
51
+ return [
52
+ "claude",
53
+ "--print",
54
+ "--model",
55
+ model,
56
+ "--effort",
57
+ effort,
58
+ "--permission-mode",
59
+ "bypassPermissions",
60
+ "--verbose",
61
+ "--output-format",
62
+ "stream-json",
63
+ "--include-partial-messages",
64
+ prompt,
65
+ ]
66
+
67
+
68
+ def _extract_claude_final_text(raw: str) -> str:
69
+ """Pull plain-text output from claude's ``--output-format stream-json`` stream.
70
+
71
+ Claude emits NDJSON events. Prefer the last valid top-level ``result``
72
+ string; otherwise join assistant text blocks; otherwise return ``raw``
73
+ unchanged so upstream errors like "produced no output" stay meaningful.
74
+ """
75
+ last_result: str | None = None
76
+ assistant_messages: list[str] = []
77
+ for line in raw.splitlines():
78
+ if not line.lstrip().startswith("{"):
79
+ continue
80
+ try:
81
+ event = json.loads(line)
82
+ except ValueError:
83
+ continue
84
+ if not isinstance(event, dict):
85
+ continue
86
+ if event.get("type") == "result":
87
+ if event.get("is_error") is True:
88
+ continue
89
+ result = event.get("result")
90
+ if isinstance(result, str) and result:
91
+ last_result = result
92
+ continue
93
+ if event.get("type") != "assistant":
94
+ continue
95
+ message = event.get("message")
96
+ if not isinstance(message, dict):
97
+ continue
98
+ content = message.get("content")
99
+ if not isinstance(content, list):
100
+ continue
101
+ parts = [
102
+ text
103
+ for block in content
104
+ if isinstance(block, dict)
105
+ and block.get("type") == "text"
106
+ and isinstance(text := block.get("text"), str)
107
+ and text
108
+ ]
109
+ if parts:
110
+ assistant_messages.append("".join(parts))
111
+ if last_result is not None:
112
+ return last_result
113
+ if assistant_messages:
114
+ return "\n".join(assistant_messages)
115
+ return raw
116
+
117
+
118
+ def _require_agent_on_path(agent: str) -> None:
119
+ _require_supported_agent(agent)
120
+ if which(agent) is None:
121
+ raise ContinuousRefactorError(f"Required command not found in PATH: {agent}")
122
+
123
+
124
+ def _raise_process_launch_error(
125
+ command: Sequence[str],
126
+ cwd: Path,
127
+ exc: OSError,
128
+ ) -> None:
129
+ command_name = _command_display_name(command)
130
+ raise ContinuousRefactorError(
131
+ f"Failed to start {command_name} in {cwd}: {exc}"
132
+ ) from exc
133
+
134
+
135
+ def _build_interactive_command(
136
+ agent: str,
137
+ model: str,
138
+ effort: str,
139
+ prompt: str,
140
+ repo_root: Path,
141
+ ) -> list[str]:
142
+ _require_supported_agent(agent)
143
+ if agent == "codex":
144
+ return _build_codex_interactive_command(model, effort, prompt, repo_root)
145
+ return _build_claude_interactive_command(model, effort, prompt)
146
+
147
+
148
+ def _build_codex_command(
149
+ model: str,
150
+ effort: str,
151
+ prompt: str,
152
+ repo_root: Path,
153
+ *,
154
+ last_message_path: Path,
155
+ ) -> list[str]:
156
+ return [
157
+ "codex",
158
+ "exec",
159
+ "--model",
160
+ model,
161
+ "--config",
162
+ f"model_reasoning_effort={effort}",
163
+ "--dangerously-bypass-approvals-and-sandbox",
164
+ "--output-last-message",
165
+ str(last_message_path),
166
+ "--cd",
167
+ str(repo_root),
168
+ prompt,
169
+ ]
170
+
171
+
172
+ def build_command(
173
+ agent: str,
174
+ model: str,
175
+ effort: str,
176
+ prompt: str,
177
+ repo_root: Path,
178
+ *,
179
+ last_message_path: Path | None = None,
180
+ ) -> list[str]:
181
+ _require_supported_agent(agent)
182
+ if agent == "codex":
183
+ if last_message_path is None:
184
+ raise ContinuousRefactorError(
185
+ "Codex runs require a last-message artifact path."
186
+ )
187
+ return _build_codex_command(
188
+ model=model,
189
+ effort=effort,
190
+ prompt=prompt,
191
+ repo_root=repo_root,
192
+ last_message_path=last_message_path,
193
+ )
194
+ return _build_claude_command(model, effort, prompt)
195
+
196
+
197
+ def _build_codex_interactive_command(
198
+ model: str,
199
+ effort: str,
200
+ prompt: str,
201
+ repo_root: Path,
202
+ ) -> list[str]:
203
+ return [
204
+ "codex",
205
+ "--model",
206
+ model,
207
+ "--config",
208
+ f"model_reasoning_effort={effort}",
209
+ "--dangerously-bypass-approvals-and-sandbox",
210
+ "--cd",
211
+ str(repo_root),
212
+ prompt,
213
+ ]
214
+
215
+
216
+ def _build_claude_interactive_command(
217
+ model: str,
218
+ effort: str,
219
+ prompt: str,
220
+ ) -> list[str]:
221
+ return [
222
+ "claude",
223
+ "--model",
224
+ model,
225
+ "--effort",
226
+ effort,
227
+ "--permission-mode",
228
+ "bypassPermissions",
229
+ prompt,
230
+ ]
231
+
232
+
233
+ def run_agent_interactive(
234
+ agent: str,
235
+ model: str,
236
+ effort: str,
237
+ prompt: str,
238
+ repo_root: Path,
239
+ ) -> int:
240
+ """Exec the agent attached to the user's terminal. Returns exit code."""
241
+ _require_agent_on_path(agent)
242
+ command = _build_interactive_command(agent, model, effort, prompt, repo_root)
243
+ terminal_fd = _terminal_control_fd()
244
+ terminal_state = _capture_terminal_state(terminal_fd)
245
+ try:
246
+ try:
247
+ return subprocess.call(command, cwd=repo_root)
248
+ except OSError as exc:
249
+ _raise_process_launch_error(command, repo_root, exc)
250
+ finally:
251
+ _restore_terminal_state(terminal_fd, terminal_state)
252
+
253
+
254
+ def _read_sha256(path: Path) -> str | None:
255
+ try:
256
+ data = path.read_bytes()
257
+ except OSError:
258
+ return None
259
+ return hashlib.sha256(data).hexdigest()
260
+
261
+
262
+ def _read_settle_digest(path: Path) -> str | None:
263
+ try:
264
+ text = path.read_text(encoding="utf-8").strip()
265
+ except OSError:
266
+ return None
267
+ prefix = "sha256:"
268
+ if not text.startswith(prefix):
269
+ return None
270
+ digest = text[len(prefix):].strip().lower()
271
+ if len(digest) != 64:
272
+ return None
273
+ if any(char not in "0123456789abcdef" for char in digest):
274
+ return None
275
+ return digest
276
+
277
+
278
+ def _interactive_settle_fingerprint(
279
+ content_path: Path,
280
+ settle_path: Path,
281
+ ) -> tuple[str, int, int, int, int] | None:
282
+ expected_digest = _read_settle_digest(settle_path)
283
+ if expected_digest is None:
284
+ return None
285
+ actual_digest = _read_sha256(content_path)
286
+ if actual_digest != expected_digest:
287
+ return None
288
+ try:
289
+ content_stat = content_path.stat()
290
+ settle_stat = settle_path.stat()
291
+ except OSError:
292
+ return None
293
+ return (
294
+ actual_digest,
295
+ content_stat.st_size,
296
+ content_stat.st_mtime_ns,
297
+ settle_stat.st_size,
298
+ settle_stat.st_mtime_ns,
299
+ )
300
+
301
+
302
+ def _send_signal_and_wait_for_exit(
303
+ process: subprocess.Popen[object],
304
+ signal_to_send: int,
305
+ *,
306
+ timeout: float,
307
+ ) -> bool:
308
+ if process.poll() is not None:
309
+ return True
310
+
311
+ try:
312
+ process.send_signal(signal_to_send)
313
+ except (OSError, ValueError):
314
+ return process.poll() is not None
315
+
316
+ try:
317
+ process.wait(timeout=timeout)
318
+ except subprocess.TimeoutExpired:
319
+ return process.poll() is not None
320
+ return True
321
+
322
+
323
+ def _terminal_control_fd() -> int | None:
324
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
325
+ try:
326
+ if stream.isatty():
327
+ return stream.fileno()
328
+ except (AttributeError, OSError, ValueError):
329
+ continue
330
+ return None
331
+
332
+
333
+ def _capture_terminal_state(fd: int | None) -> object | None:
334
+ if fd is None or termios is None:
335
+ return None
336
+ try:
337
+ return termios.tcgetattr(fd)
338
+ except (termios.error, OSError, ValueError):
339
+ return None
340
+
341
+
342
+ def _restore_terminal_state(fd: int | None, state: object | None) -> None:
343
+ if fd is None or state is None or termios is None:
344
+ return
345
+ try:
346
+ termios.tcsetattr(fd, termios.TCSADRAIN, state)
347
+ except (termios.error, OSError, ValueError):
348
+ pass
349
+
350
+
351
+ _FORCED_CODEX_TERMINAL_RESET = (
352
+ b"\x1b[<u" # Pop keyboard enhancement flags.
353
+ b"\x1b[?2004l" # Disable bracketed paste.
354
+ b"\x1b[?1004l" # Disable focus reporting.
355
+ b"\x1b[>4m" # Disable modifyOtherKeys.
356
+ b"\x1b[?25h" # Show cursor.
357
+ )
358
+
359
+
360
+ def _restore_codex_terminal_modes_after_forced_stop() -> None:
361
+ for stream in (sys.stdout, sys.stderr):
362
+ try:
363
+ if not stream.isatty():
364
+ continue
365
+ buffer = getattr(stream, "buffer", None)
366
+ if buffer is not None:
367
+ buffer.write(_FORCED_CODEX_TERMINAL_RESET)
368
+ buffer.flush()
369
+ return
370
+ stream.write(_FORCED_CODEX_TERMINAL_RESET.decode("ascii"))
371
+ stream.flush()
372
+ return
373
+ except (AttributeError, OSError, ValueError):
374
+ continue
375
+
376
+ try:
377
+ with open("/dev/tty", "wb", buffering=0) as tty:
378
+ tty.write(_FORCED_CODEX_TERMINAL_RESET)
379
+ except OSError:
380
+ pass
381
+
382
+
383
+ def _flush_terminal_input(fd: int | None) -> None:
384
+ if fd is None or termios is None:
385
+ return
386
+ try:
387
+ termios.tcflush(fd, termios.TCIFLUSH)
388
+ except (termios.error, OSError, ValueError):
389
+ pass
390
+
391
+
392
+ def _gracefully_stop_interactive_process(
393
+ process: subprocess.Popen[object],
394
+ *,
395
+ interrupt_timeout: float = 1.0,
396
+ terminate_timeout: float = 2.0,
397
+ ) -> None:
398
+ if _send_signal_and_wait_for_exit(
399
+ process,
400
+ signal.SIGINT,
401
+ timeout=interrupt_timeout,
402
+ ):
403
+ return
404
+
405
+ if _send_signal_and_wait_for_exit(
406
+ process,
407
+ signal.SIGTERM,
408
+ timeout=terminate_timeout,
409
+ ):
410
+ return
411
+
412
+ try:
413
+ process.kill()
414
+ except OSError:
415
+ pass
416
+ try:
417
+ process.wait()
418
+ except OSError:
419
+ pass
420
+
421
+
422
+ def run_agent_interactive_until_settled(
423
+ agent: str,
424
+ model: str,
425
+ effort: str,
426
+ prompt: str,
427
+ repo_root: Path,
428
+ *,
429
+ content_path: Path,
430
+ settle_path: Path,
431
+ settle_window_seconds: float = 2.0,
432
+ poll_interval_seconds: float = 0.1,
433
+ ) -> int:
434
+ _require_agent_on_path(agent)
435
+
436
+ settle_path.parent.mkdir(parents=True, exist_ok=True)
437
+ if settle_path.exists():
438
+ if settle_path.is_dir():
439
+ raise ContinuousRefactorError(f"Settle path is a directory: {settle_path}")
440
+ settle_path.unlink()
441
+
442
+ command = _build_interactive_command(agent, model, effort, prompt, repo_root)
443
+ terminal_fd = _terminal_control_fd()
444
+ terminal_state = _capture_terminal_state(terminal_fd)
445
+ try:
446
+ process = subprocess.Popen(command, cwd=repo_root)
447
+ except OSError as exc:
448
+ _raise_process_launch_error(command, repo_root, exc)
449
+ settled_since: float | None = None
450
+ last_fingerprint: tuple[str, int, int, int, int] | None = None
451
+ forced_codex_stop = False
452
+
453
+ try:
454
+ while True:
455
+ fingerprint = _interactive_settle_fingerprint(content_path, settle_path)
456
+ if fingerprint is None:
457
+ settled_since = None
458
+ last_fingerprint = None
459
+ elif fingerprint != last_fingerprint:
460
+ last_fingerprint = fingerprint
461
+ settled_since = time.monotonic()
462
+ elif settled_since is not None:
463
+ elapsed = time.monotonic() - settled_since
464
+ if elapsed >= settle_window_seconds:
465
+ returncode = process.poll()
466
+ if returncode is not None:
467
+ return returncode
468
+ forced_codex_stop = agent == "codex"
469
+ _gracefully_stop_interactive_process(process)
470
+ return 0
471
+
472
+ returncode = process.poll()
473
+ if returncode is not None:
474
+ if fingerprint is not None:
475
+ return returncode
476
+ raise ContinuousRefactorError(
477
+ "interactive agent exited before the settled write was confirmed"
478
+ )
479
+
480
+ time.sleep(poll_interval_seconds)
481
+ finally:
482
+ if forced_codex_stop:
483
+ _restore_codex_terminal_modes_after_forced_stop()
484
+ _restore_terminal_state(terminal_fd, terminal_state)
485
+ if forced_codex_stop:
486
+ _flush_terminal_input(terminal_fd)
487
+
488
+
489
+ def maybe_run_agent(
490
+ agent: str,
491
+ model: str,
492
+ effort: str,
493
+ prompt: str,
494
+ repo_root: Path,
495
+ *,
496
+ stdout_path: Path,
497
+ stderr_path: Path,
498
+ last_message_path: Path | None = None,
499
+ mirror_to_terminal: bool = True,
500
+ timeout: int | None = None,
501
+ ) -> CommandCapture:
502
+ _require_agent_on_path(agent)
503
+ command = build_command(
504
+ agent=agent,
505
+ model=model,
506
+ effort=effort,
507
+ prompt=prompt,
508
+ repo_root=repo_root,
509
+ last_message_path=last_message_path,
510
+ )
511
+ capture = run_observed_command(
512
+ command,
513
+ cwd=repo_root,
514
+ stdout_path=stdout_path,
515
+ stderr_path=stderr_path,
516
+ mirror_to_terminal=mirror_to_terminal,
517
+ timeout=timeout,
518
+ )
519
+ if agent == "claude":
520
+ return replace(capture, stdout=_extract_claude_final_text(capture.stdout))
521
+ return capture
522
+
523
+
524
+ def _write_timestamped_line(handle: TextIO, line: str) -> None:
525
+ suffix = "" if line.endswith("\n") else "\n"
526
+ handle.write(f"[{iso_timestamp()}] {line}{suffix}")
527
+ handle.flush()
528
+
529
+
530
+ def _stream_pipe(
531
+ pipe: TextIO,
532
+ sink: TextIO,
533
+ mirror: TextIO | None,
534
+ chunks: list[str],
535
+ ) -> None:
536
+ for line in pipe:
537
+ chunks.append(line)
538
+ _write_timestamped_line(sink, line)
539
+ if mirror is not None:
540
+ mirror.write(line)
541
+ mirror.flush()
542
+ pipe.close()
543
+
544
+
545
+ def _terminate_process(process: subprocess.Popen[str]) -> None:
546
+ """SIGTERM then SIGKILL if the process doesn't exit within 5 seconds."""
547
+ try:
548
+ process.terminate()
549
+ try:
550
+ process.wait(timeout=5)
551
+ except subprocess.TimeoutExpired:
552
+ process.kill()
553
+ except OSError:
554
+ pass
555
+
556
+
557
+ def _command_display_name(command: Sequence[str]) -> str:
558
+ return Path(command[0]).name or str(command[0])
559
+
560
+
561
+ @dataclass(frozen=True)
562
+ class _ObservedCommandOutcome:
563
+ returncode: int
564
+ timed_out: bool
565
+ was_stuck: bool
566
+
567
+
568
+ def _wait_for_observed_command(
569
+ process: subprocess.Popen[str],
570
+ *,
571
+ timeout: int | None,
572
+ stdout_thread: threading.Thread,
573
+ stderr_thread: threading.Thread,
574
+ stop_watchdog: threading.Event,
575
+ watchdog_thread: threading.Thread,
576
+ stuck_detected: threading.Event,
577
+ ) -> _ObservedCommandOutcome:
578
+ timed_out = False
579
+ try:
580
+ returncode = process.wait(timeout=timeout)
581
+ except subprocess.TimeoutExpired:
582
+ timed_out = True
583
+ _terminate_process(process)
584
+ returncode = process.wait()
585
+
586
+ stdout_thread.join()
587
+ stderr_thread.join()
588
+ stop_watchdog.set()
589
+ watchdog_thread.join(timeout=10)
590
+
591
+ return _ObservedCommandOutcome(
592
+ returncode=returncode,
593
+ timed_out=timed_out,
594
+ was_stuck=stuck_detected.is_set(),
595
+ )
596
+
597
+
598
+ def run_observed_command(
599
+ command: Sequence[str],
600
+ cwd: Path,
601
+ *,
602
+ stdout_path: Path,
603
+ stderr_path: Path,
604
+ mirror_to_terminal: bool,
605
+ timeout: int | None = None,
606
+ stuck_interval: int = 30,
607
+ stuck_timeout: int = 300,
608
+ ) -> CommandCapture:
609
+ stdout_path.parent.mkdir(parents=True, exist_ok=True)
610
+ stderr_path.parent.mkdir(parents=True, exist_ok=True)
611
+ try:
612
+ process = subprocess.Popen(
613
+ command,
614
+ cwd=cwd,
615
+ text=True,
616
+ shell=False,
617
+ stdout=subprocess.PIPE,
618
+ stderr=subprocess.PIPE,
619
+ bufsize=1,
620
+ )
621
+ except OSError as exc:
622
+ _raise_process_launch_error(command, cwd, exc)
623
+ if process.stdout is None or process.stderr is None:
624
+ command_name = _command_display_name(command)
625
+ raise ContinuousRefactorError(
626
+ f"Failed to capture process output for {command_name}"
627
+ )
628
+
629
+ stdout_chunks: list[str] = []
630
+ stderr_chunks: list[str] = []
631
+ stop_watchdog = threading.Event()
632
+ stuck_detected = threading.Event()
633
+
634
+ def watchdog() -> None:
635
+ last_count = 0
636
+ stale_since: float | None = None
637
+ while not stop_watchdog.wait(timeout=stuck_interval):
638
+ if process.poll() is not None:
639
+ return
640
+ current_count = len(stdout_chunks) + len(stderr_chunks)
641
+ if current_count != last_count:
642
+ last_count = current_count
643
+ stale_since = None
644
+ else:
645
+ if stale_since is None:
646
+ stale_since = time.monotonic()
647
+ elif time.monotonic() - stale_since >= stuck_timeout:
648
+ _terminate_process(process)
649
+ stuck_detected.set()
650
+ return
651
+
652
+ with (
653
+ stdout_path.open("w", encoding="utf-8") as stdout_handle,
654
+ stderr_path.open("w", encoding="utf-8") as stderr_handle,
655
+ ):
656
+ stdout_thread = threading.Thread(
657
+ target=_stream_pipe,
658
+ args=(
659
+ process.stdout,
660
+ stdout_handle,
661
+ sys.stdout if mirror_to_terminal else None,
662
+ stdout_chunks,
663
+ ),
664
+ )
665
+ stderr_thread = threading.Thread(
666
+ target=_stream_pipe,
667
+ args=(
668
+ process.stderr,
669
+ stderr_handle,
670
+ sys.stderr if mirror_to_terminal else None,
671
+ stderr_chunks,
672
+ ),
673
+ )
674
+ stdout_thread.start()
675
+ stderr_thread.start()
676
+
677
+ watchdog_thread = threading.Thread(target=watchdog, daemon=True)
678
+ watchdog_thread.start()
679
+
680
+ outcome = _wait_for_observed_command(
681
+ process,
682
+ timeout=timeout,
683
+ stdout_thread=stdout_thread,
684
+ stderr_thread=stderr_thread,
685
+ stop_watchdog=stop_watchdog,
686
+ watchdog_thread=watchdog_thread,
687
+ stuck_detected=stuck_detected,
688
+ )
689
+
690
+ if not stdout_chunks:
691
+ _write_timestamped_line(stdout_handle, "<no output>")
692
+ if not stderr_chunks:
693
+ _write_timestamped_line(stderr_handle, "<no output>")
694
+
695
+ if outcome.timed_out:
696
+ command_name = _command_display_name(command)
697
+ raise ContinuousRefactorError(f"{command_name} timed out after {timeout}s")
698
+ if outcome.was_stuck:
699
+ command_name = _command_display_name(command)
700
+ raise ContinuousRefactorError(
701
+ f"{command_name} produced no output for {stuck_timeout}s"
702
+ )
703
+
704
+ return CommandCapture(
705
+ command=tuple(command),
706
+ returncode=outcome.returncode,
707
+ stdout="".join(stdout_chunks),
708
+ stderr="".join(stderr_chunks),
709
+ stdout_path=stdout_path,
710
+ stderr_path=stderr_path,
711
+ )
712
+
713
+
714
+ def run_tests(
715
+ test_command: str,
716
+ repo_root: Path,
717
+ stdout_path: Path,
718
+ stderr_path: Path,
719
+ *,
720
+ mirror_to_terminal: bool = False,
721
+ ) -> CommandCapture:
722
+ return run_observed_command(
723
+ shlex.split(test_command),
724
+ cwd=repo_root,
725
+ stdout_path=stdout_path,
726
+ stderr_path=stderr_path,
727
+ mirror_to_terminal=mirror_to_terminal,
728
+ )
729
+
730
+
731
+ def summarize_output(result: CommandCapture) -> str:
732
+ lines = (result.stdout + result.stderr).splitlines()
733
+ return "\n".join(lines[-40:])