etch-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.
etch/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
etch/agent.py ADDED
@@ -0,0 +1,91 @@
1
+ """Claude agent subprocess runner."""
2
+
3
+ import subprocess
4
+ import threading
5
+ from collections.abc import Callable
6
+
7
+
8
+ class AgentError(Exception):
9
+ """Raised when the agent subprocess fails or is unavailable."""
10
+
11
+
12
+ def run(
13
+ prompt: str,
14
+ verbose: bool = False,
15
+ tick_callback: Callable[[str], None] | None = None,
16
+ ) -> str:
17
+ """Run the Claude agent with the given prompt piped to stdin.
18
+
19
+ Launches `claude -p --dangerously-skip-permissions` as a subprocess,
20
+ pipes `prompt` to its stdin, and returns the full stdout.
21
+
22
+ Args:
23
+ prompt: The prompt text to send to Claude.
24
+ verbose: If True, streams output to terminal in addition to capturing it.
25
+ tick_callback: Optional callable called with each line of output as it
26
+ arrives. Used by display layer for streaming updates.
27
+
28
+ Returns:
29
+ The full stdout output from the agent as a string.
30
+
31
+ Raises:
32
+ AgentError: If `claude` is not found, or exits with a non-zero code.
33
+ """
34
+ cmd = ["claude", "-p", "--dangerously-skip-permissions"]
35
+
36
+ try:
37
+ process = subprocess.Popen(
38
+ cmd,
39
+ stdin=subprocess.PIPE,
40
+ stdout=subprocess.PIPE,
41
+ stderr=subprocess.PIPE,
42
+ text=True,
43
+ encoding="utf-8",
44
+ errors="replace",
45
+ )
46
+ except FileNotFoundError:
47
+ raise AgentError(
48
+ "claude executable not found. "
49
+ "Is Claude Code installed? Run: npm install -g @anthropic-ai/claude-code"
50
+ )
51
+ except OSError as exc:
52
+ raise AgentError(f"Failed to launch claude: {exc}") from exc
53
+
54
+ # Write prompt to stdin and close it
55
+ try:
56
+ process.stdin.write(prompt)
57
+ process.stdin.close()
58
+ except BrokenPipeError as exc:
59
+ raise AgentError(f"Failed to write prompt to claude stdin: {exc}") from exc
60
+
61
+ output_lines: list[str] = []
62
+ lock = threading.Lock()
63
+
64
+ def read_stdout() -> None:
65
+ assert process.stdout is not None
66
+ for line in process.stdout:
67
+ with lock:
68
+ output_lines.append(line)
69
+ if verbose:
70
+ print(line, end="", flush=True)
71
+ if tick_callback is not None:
72
+ tick_callback(line)
73
+
74
+ reader = threading.Thread(target=read_stdout, daemon=True)
75
+ reader.start()
76
+ reader.join()
77
+
78
+ process.wait()
79
+
80
+ # Capture stderr for error reporting
81
+ stderr_output = ""
82
+ if process.stderr:
83
+ stderr_output = process.stderr.read().strip()
84
+
85
+ if process.returncode != 0:
86
+ detail = stderr_output or "(no stderr)"
87
+ raise AgentError(
88
+ f"claude exited with code {process.returncode}: {detail}"
89
+ )
90
+
91
+ return "".join(output_lines)
etch/cli.py ADDED
@@ -0,0 +1,90 @@
1
+ """CLI entry points for etch-loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import typer
11
+
12
+ from etch import display, loop
13
+
14
+ app = typer.Typer(
15
+ name="etch",
16
+ help="Run Claude Code in a fix-break loop, hunting for edge cases.",
17
+ add_completion=False,
18
+ pretty_exceptions_show_locals=False,
19
+ )
20
+
21
+ _TEMPLATES = ["ETCH.md", "BREAK.md"]
22
+
23
+
24
+ @app.command()
25
+ def init() -> None:
26
+ """Copy ETCH.md and BREAK.md templates into the current directory."""
27
+ for filename in _TEMPLATES:
28
+ dest = Path.cwd() / filename
29
+
30
+ if dest.exists():
31
+ display.print_init_skip(filename)
32
+ continue
33
+
34
+ try:
35
+ # Python 3.9+ traversable API
36
+ pkg_templates = importlib.resources.files("etch") / "templates" / filename
37
+ content = pkg_templates.read_text(encoding="utf-8")
38
+ dest.write_text(content, encoding="utf-8")
39
+ display.print_init_ok(filename)
40
+ except (FileNotFoundError, ModuleNotFoundError, TypeError) as exc:
41
+ display.print_error(f"Could not copy {filename}: {exc}")
42
+
43
+
44
+ @app.command()
45
+ def run(
46
+ prompt: str = typer.Option(
47
+ "./ETCH.md",
48
+ "--prompt",
49
+ help="Path to the fixer prompt file (ETCH.md).",
50
+ show_default=True,
51
+ ),
52
+ max_iterations: int = typer.Option(
53
+ 20,
54
+ "--max-iterations",
55
+ "-n",
56
+ help="Maximum number of fix-break cycles.",
57
+ show_default=True,
58
+ min=1,
59
+ ),
60
+ no_commit: bool = typer.Option(
61
+ False,
62
+ "--no-commit",
63
+ help="Skip git commits after fixer runs.",
64
+ is_flag=True,
65
+ ),
66
+ dry_run: bool = typer.Option(
67
+ False,
68
+ "--dry-run",
69
+ help="Print the fixer prompt and exit without running.",
70
+ is_flag=True,
71
+ ),
72
+ verbose: bool = typer.Option(
73
+ False,
74
+ "--verbose",
75
+ help="Stream agent output to the terminal.",
76
+ is_flag=True,
77
+ ),
78
+ ) -> None:
79
+ """Run the fix-break loop against the current repository."""
80
+ try:
81
+ loop.run(
82
+ prompt_path=prompt,
83
+ max_iterations=max_iterations,
84
+ no_commit=no_commit,
85
+ dry_run=dry_run,
86
+ verbose=verbose,
87
+ )
88
+ except KeyboardInterrupt:
89
+ display.print_interrupted()
90
+ raise typer.Exit(code=130)
etch/display.py ADDED
@@ -0,0 +1,389 @@
1
+ """Rich-based terminal UI for etch-loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from rich.columns import Columns
12
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
13
+ from rich.live import Live
14
+ from rich.panel import Panel
15
+ from rich.segment import Segment
16
+ from rich.style import Style
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ from etch import __version__
21
+
22
+ # ── Palette ──────────────────────────────────────────────────────────────────
23
+ BG = "#0a0a0a"
24
+ FG = "#e8e8e8"
25
+ DIM = "#555555"
26
+ AMBER = "#d4a547"
27
+ GREEN = "#7aba78"
28
+ RED = "#c96a6a"
29
+ BORDER = "#1e1e1e"
30
+
31
+ # ── Symbols (ASCII only) ──────────────────────────────────────────────────────
32
+ SYM_RUN = ">"
33
+ SYM_OK = "+"
34
+ SYM_FAIL = "x"
35
+ SYM_NEUTRAL = "-"
36
+
37
+ SCAN_WIDTH = 40
38
+ SCAN_BLOCK = "▓▒ " # 3-char block
39
+ SCAN_FILL = "░"
40
+ TICK_MS = 80
41
+
42
+
43
+ # ── ScanBar renderable ────────────────────────────────────────────────────────
44
+
45
+
46
+ class ScanBar:
47
+ """A Rich renderable that renders one frame of the scan animation.
48
+
49
+ The bar is SCAN_WIDTH chars wide, filled with SCAN_FILL (░).
50
+ A 3-char SCAN_BLOCK (▓▒ ) slides left-to-right, wrapping at the end.
51
+ Rendered in amber.
52
+ """
53
+
54
+ def __init__(self, tick: int) -> None:
55
+ self.tick = tick
56
+
57
+ def __rich_console__(
58
+ self, console: Console, options: ConsoleOptions
59
+ ) -> RenderResult:
60
+ width = SCAN_WIDTH
61
+ block = SCAN_BLOCK
62
+ block_len = len(block)
63
+ pos = self.tick % width
64
+
65
+ bar = list(SCAN_FILL * width)
66
+ for i, ch in enumerate(block):
67
+ idx = (pos + i) % width
68
+ bar[idx] = ch
69
+
70
+ yield Segment("".join(bar), Style(color=AMBER))
71
+
72
+
73
+ # ── Log entry dataclass ───────────────────────────────────────────────────────
74
+
75
+
76
+ @dataclass
77
+ class _LogEntry:
78
+ """One line in the scrolling log."""
79
+
80
+ symbol: str
81
+ phase: str
82
+ status: str
83
+ detail: str | RenderableType
84
+ color: str
85
+ running: bool = False
86
+ tick: int = 0
87
+
88
+
89
+ # ── EtchDisplay ──────────────────────────────────────────────────────────────
90
+
91
+
92
+ class EtchDisplay:
93
+ """Manages the Rich Live layout for the etch-loop run."""
94
+
95
+ def __init__(self, target: str = "") -> None:
96
+ self._console = Console(style=f"on {BG}")
97
+ self._target = target
98
+ self._entries: list[_LogEntry] = []
99
+ self._stats: dict[str, Any] = {
100
+ "iterations": 0,
101
+ "fixes": 0,
102
+ "issues": 0,
103
+ "start": time.monotonic(),
104
+ }
105
+ self._live: Live | None = None
106
+ self._tick = 0
107
+ self._ticker_stop = threading.Event()
108
+ self._ticker_thread: threading.Thread | None = None
109
+ self._lock = threading.Lock()
110
+
111
+ # ── Public lifecycle ──────────────────────────────────────────────────────
112
+
113
+ def __enter__(self) -> "EtchDisplay":
114
+ self._live = Live(
115
+ self._render(),
116
+ console=self._console,
117
+ refresh_per_second=15,
118
+ transient=False,
119
+ )
120
+ self._live.__enter__()
121
+ self._start_ticker()
122
+ return self
123
+
124
+ def __exit__(self, *args: Any) -> None:
125
+ self._stop_ticker()
126
+ if self._live is not None:
127
+ self._live.__exit__(*args)
128
+
129
+ # ── Iteration / phase API ─────────────────────────────────────────────────
130
+
131
+ def start_iteration(self, n: int) -> None:
132
+ """Add an iteration header line to the log."""
133
+ with self._lock:
134
+ self._stats["iterations"] = n
135
+ self._entries.append(
136
+ _LogEntry(
137
+ symbol=SYM_NEUTRAL,
138
+ phase=f"iteration {n}",
139
+ status="",
140
+ detail="",
141
+ color=DIM,
142
+ )
143
+ )
144
+ self._refresh()
145
+
146
+ def start_phase(self, phase: str) -> None:
147
+ """Add a 'running' line with scan animation for the given phase."""
148
+ with self._lock:
149
+ self._entries.append(
150
+ _LogEntry(
151
+ symbol=SYM_RUN,
152
+ phase=phase,
153
+ status="running",
154
+ detail=ScanBar(self._tick),
155
+ color=AMBER,
156
+ running=True,
157
+ tick=self._tick,
158
+ )
159
+ )
160
+ self._refresh()
161
+
162
+ def finish_phase(
163
+ self,
164
+ phase: str,
165
+ status: str,
166
+ detail: str,
167
+ duration: float,
168
+ success: bool = True,
169
+ ) -> None:
170
+ """Replace the last running line for `phase` with a finished result."""
171
+ color = GREEN if success else RED
172
+ symbol = SYM_OK if success else SYM_FAIL
173
+ dur_str = f"{duration:.1f}s"
174
+
175
+ with self._lock:
176
+ # Find the last running entry for this phase and replace it
177
+ for i in range(len(self._entries) - 1, -1, -1):
178
+ entry = self._entries[i]
179
+ if entry.phase == phase and entry.running:
180
+ self._entries[i] = _LogEntry(
181
+ symbol=symbol,
182
+ phase=phase,
183
+ status=status,
184
+ detail=detail,
185
+ color=color,
186
+ running=False,
187
+ )
188
+ break
189
+ self._refresh()
190
+
191
+ def record_fix(self) -> None:
192
+ """Increment the fix counter."""
193
+ with self._lock:
194
+ self._stats["fixes"] += 1
195
+
196
+ def record_issue(self) -> None:
197
+ """Increment the breaker-issues counter."""
198
+ with self._lock:
199
+ self._stats["issues"] += 1
200
+
201
+ def print_summary(self, stats: dict[str, Any]) -> None:
202
+ """Print the final done/stopped/interrupted panel."""
203
+ self._stop_ticker()
204
+ if self._live is not None:
205
+ self._live.stop()
206
+
207
+ reason = stats.get("reason", "done")
208
+ elapsed = stats.get("elapsed", 0.0)
209
+ iterations = stats.get("iterations", 0)
210
+ fixes = stats.get("fixes", 0)
211
+ issues = stats.get("issues", 0)
212
+
213
+ elapsed_str = _format_elapsed(elapsed)
214
+
215
+ if reason == "clear":
216
+ title_color = GREEN
217
+ title = f"[{GREEN}]+ all clear[/{GREEN}]"
218
+ elif reason == "interrupted":
219
+ title_color = AMBER
220
+ title = f"[{AMBER}]- interrupted[/{AMBER}]"
221
+ elif reason == "max_iterations":
222
+ title_color = AMBER
223
+ title = f"[{AMBER}]- stopped (max iterations)[/{AMBER}]"
224
+ elif reason == "no_changes":
225
+ title_color = GREEN
226
+ title = f"[{GREEN}]+ clean — fixer found nothing[/{GREEN}]"
227
+ else:
228
+ title_color = FG
229
+ title = f"[{FG}]done[/{FG}]"
230
+
231
+ body = (
232
+ f"[{DIM}]iterations[/{DIM}] [{FG}]{iterations}[/{FG}] "
233
+ f"[{DIM}]fixes[/{DIM}] [{FG}]{fixes}[/{FG}] "
234
+ f"[{DIM}]breaker issues[/{DIM}] [{FG}]{issues}[/{FG}] "
235
+ f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
236
+ )
237
+
238
+ panel = Panel(
239
+ body,
240
+ title=title,
241
+ border_style=Style(color=BORDER),
242
+ style=Style(bgcolor=BG),
243
+ )
244
+ self._console.print(panel)
245
+
246
+ # ── Rendering ─────────────────────────────────────────────────────────────
247
+
248
+ def _render(self) -> RenderableType:
249
+ """Build the full Live renderable."""
250
+ with self._lock:
251
+ entries = list(self._entries)
252
+ tick = self._tick
253
+ stats = dict(self._stats)
254
+
255
+ elapsed = time.monotonic() - stats["start"]
256
+
257
+ # Build log table
258
+ log_table = Table.grid(padding=(0, 1))
259
+ log_table.add_column(width=2) # symbol
260
+ log_table.add_column(width=10) # phase
261
+ log_table.add_column(width=12) # status
262
+ log_table.add_column() # detail
263
+
264
+ for entry in entries:
265
+ if entry.running:
266
+ detail_render: RenderableType = ScanBar(tick)
267
+ else:
268
+ detail_render = Text(str(entry.detail), style=Style(color=DIM))
269
+
270
+ sym_text = Text(entry.symbol, style=Style(color=entry.color))
271
+ phase_text = Text(entry.phase, style=Style(color=entry.color))
272
+ status_text = Text(entry.status, style=Style(color=DIM))
273
+
274
+ log_table.add_row(sym_text, phase_text, status_text, detail_render)
275
+
276
+ # Header
277
+ title_str = f"etch loop v{__version__}"
278
+ if self._target:
279
+ title_str += f" {self._target}"
280
+
281
+ # Stats footer
282
+ elapsed_str = _format_elapsed(elapsed)
283
+ footer = (
284
+ f"[{DIM}]iterations[/{DIM}] [{FG}]{stats['iterations']}[/{FG}] "
285
+ f"[{DIM}]fixes[/{DIM}] [{FG}]{stats['fixes']}[/{FG}] "
286
+ f"[{DIM}]breaker issues[/{DIM}] [{FG}]{stats['issues']}[/{FG}] "
287
+ f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
288
+ )
289
+
290
+ panel = Panel(
291
+ log_table,
292
+ title=f"[{AMBER}]{title_str}[/{AMBER}]",
293
+ subtitle=footer,
294
+ border_style=Style(color=BORDER),
295
+ style=Style(bgcolor=BG),
296
+ )
297
+ return panel
298
+
299
+ def _refresh(self) -> None:
300
+ if self._live is not None:
301
+ self._live.update(self._render())
302
+
303
+ # ── Ticker thread ─────────────────────────────────────────────────────────
304
+
305
+ def _start_ticker(self) -> None:
306
+ self._ticker_stop.clear()
307
+ self._ticker_thread = threading.Thread(
308
+ target=self._ticker_loop, daemon=True
309
+ )
310
+ self._ticker_thread.start()
311
+
312
+ def _stop_ticker(self) -> None:
313
+ self._ticker_stop.set()
314
+ if self._ticker_thread is not None:
315
+ self._ticker_thread.join(timeout=1.0)
316
+ self._ticker_thread = None
317
+
318
+ def _ticker_loop(self) -> None:
319
+ while not self._ticker_stop.is_set():
320
+ with self._lock:
321
+ self._tick += 1
322
+ self._refresh()
323
+ time.sleep(TICK_MS / 1000.0)
324
+
325
+
326
+ # ── Standalone print helpers (used outside Live context) ──────────────────────
327
+
328
+ _console = Console(style=f"on {BG}")
329
+
330
+
331
+ def print_dry_run(prompt_text: str) -> None:
332
+ """Print the prompt in a panel for --dry-run mode."""
333
+ _console.print(
334
+ Panel(
335
+ Text(prompt_text, style=Style(color=FG)),
336
+ title=f"[{AMBER}]dry run — prompt preview[/{AMBER}]",
337
+ border_style=Style(color=BORDER),
338
+ style=Style(bgcolor=BG),
339
+ )
340
+ )
341
+
342
+
343
+ def print_interrupted() -> None:
344
+ """Print interrupted notice."""
345
+ _console.print(
346
+ Panel(
347
+ Text("Run interrupted by user.", style=Style(color=AMBER)),
348
+ title=f"[{AMBER}]- interrupted[/{AMBER}]",
349
+ border_style=Style(color=BORDER),
350
+ style=Style(bgcolor=BG),
351
+ )
352
+ )
353
+
354
+
355
+ def print_error(message: str) -> None:
356
+ """Print an error panel."""
357
+ _console.print(
358
+ Panel(
359
+ Text(message, style=Style(color=RED)),
360
+ title=f"[{RED}]x error[/{RED}]",
361
+ border_style=Style(color=BORDER),
362
+ style=Style(bgcolor=BG),
363
+ )
364
+ )
365
+
366
+
367
+ def print_init_ok(filename: str) -> None:
368
+ """Print a success line for etch init."""
369
+ _console.print(f"[{GREEN}]{SYM_OK}[/{GREEN}] [{FG}]{filename}[/{FG}]")
370
+
371
+
372
+ def print_init_skip(filename: str) -> None:
373
+ """Print a skip line for etch init (file already exists)."""
374
+ _console.print(
375
+ f"[{AMBER}]{SYM_NEUTRAL}[/{AMBER}] [{DIM}]{filename} already exists, skipping[/{DIM}]"
376
+ )
377
+
378
+
379
+ # ── Utilities ─────────────────────────────────────────────────────────────────
380
+
381
+
382
+ def _format_elapsed(seconds: float) -> str:
383
+ """Format elapsed seconds as '2m 14s' or '45s'."""
384
+ seconds = max(0.0, seconds)
385
+ mins = int(seconds // 60)
386
+ secs = int(seconds % 60)
387
+ if mins > 0:
388
+ return f"{mins}m {secs}s"
389
+ return f"{secs}s"
etch/git.py ADDED
@@ -0,0 +1,101 @@
1
+ """Git subprocess utilities."""
2
+
3
+ import subprocess
4
+
5
+
6
+ class GitError(Exception):
7
+ """Raised when a git operation fails."""
8
+
9
+
10
+ def has_changes() -> bool:
11
+ """Check whether the working tree has uncommitted changes.
12
+
13
+ Runs `git diff --quiet HEAD` and interprets the exit code:
14
+ 0 — no changes
15
+ 1 — changes present
16
+ other — git error
17
+
18
+ Returns:
19
+ True if there are uncommitted changes, False otherwise.
20
+
21
+ Raises:
22
+ GitError: If git is not available or returns an unexpected error.
23
+ """
24
+ try:
25
+ result = subprocess.run(
26
+ ["git", "diff", "--quiet", "HEAD"],
27
+ capture_output=True,
28
+ )
29
+ except FileNotFoundError:
30
+ raise GitError("git executable not found. Is git installed?")
31
+ except OSError as exc:
32
+ raise GitError(f"Failed to run git: {exc}") from exc
33
+
34
+ if result.returncode == 0:
35
+ return False
36
+ if result.returncode == 1:
37
+ return True
38
+
39
+ # Non-zero, non-one exit code — could mean no commits yet, check with status
40
+ # Fallback: check via git status --porcelain
41
+ try:
42
+ status = subprocess.run(
43
+ ["git", "status", "--porcelain"],
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ except OSError as exc:
48
+ raise GitError(f"Failed to run git status: {exc}") from exc
49
+
50
+ if status.returncode != 0:
51
+ stderr = result.stderr.decode(errors="replace").strip()
52
+ raise GitError(f"git diff failed (exit {result.returncode}): {stderr}")
53
+
54
+ return bool(status.stdout.strip())
55
+
56
+
57
+ def commit(message: str) -> None:
58
+ """Stage all changes and create a commit.
59
+
60
+ Args:
61
+ message: The commit message.
62
+
63
+ Raises:
64
+ GitError: If staging or committing fails.
65
+ """
66
+ if not message or not message.strip():
67
+ raise GitError("Commit message must not be empty.")
68
+
69
+ # Stage all changes
70
+ try:
71
+ add_result = subprocess.run(
72
+ ["git", "add", "-A"],
73
+ capture_output=True,
74
+ text=True,
75
+ )
76
+ except FileNotFoundError:
77
+ raise GitError("git executable not found. Is git installed?")
78
+ except OSError as exc:
79
+ raise GitError(f"Failed to run git add: {exc}") from exc
80
+
81
+ if add_result.returncode != 0:
82
+ stderr = add_result.stderr.strip()
83
+ raise GitError(f"git add -A failed (exit {add_result.returncode}): {stderr}")
84
+
85
+ # Create commit
86
+ try:
87
+ commit_result = subprocess.run(
88
+ ["git", "commit", "-m", message],
89
+ capture_output=True,
90
+ text=True,
91
+ )
92
+ except OSError as exc:
93
+ raise GitError(f"Failed to run git commit: {exc}") from exc
94
+
95
+ if commit_result.returncode != 0:
96
+ stderr = commit_result.stderr.strip()
97
+ stdout = commit_result.stdout.strip()
98
+ detail = stderr or stdout
99
+ raise GitError(
100
+ f"git commit failed (exit {commit_result.returncode}): {detail}"
101
+ )
etch/loop.py ADDED
@@ -0,0 +1,182 @@
1
+ """Core fix-break loop logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from etch import agent, display, git, prompt, signals
9
+ from etch.agent import AgentError
10
+ from etch.git import GitError
11
+ from etch.prompt import PromptError
12
+
13
+
14
+ def run(
15
+ prompt_path: str | Path,
16
+ max_iterations: int = 20,
17
+ no_commit: bool = False,
18
+ dry_run: bool = False,
19
+ verbose: bool = False,
20
+ ) -> None:
21
+ """Run the fix-break loop.
22
+
23
+ Args:
24
+ prompt_path: Path to ETCH.md (fixer prompt).
25
+ max_iterations: Maximum number of fix-break cycles to run.
26
+ no_commit: If True, skip git commits after fixer runs.
27
+ dry_run: If True, print the prompt and exit without running.
28
+ verbose: If True, stream agent output to the terminal.
29
+ """
30
+ prompt_path = Path(prompt_path)
31
+
32
+ # ── Load fixer prompt ──────────────────────────────────────────────────────
33
+ try:
34
+ prompt_text = prompt.load(prompt_path)
35
+ except PromptError as exc:
36
+ display.print_error(str(exc))
37
+ return
38
+
39
+ # ── Dry run ───────────────────────────────────────────────────────────────
40
+ if dry_run:
41
+ display.print_dry_run(prompt_text)
42
+ return
43
+
44
+ # ── Load breaker prompt early to fail fast ────────────────────────────────
45
+ try:
46
+ break_text = prompt.load_break(prompt_path)
47
+ except PromptError as exc:
48
+ display.print_error(str(exc))
49
+ return
50
+
51
+ start_time = time.monotonic()
52
+ stats: dict = {
53
+ "iterations": 0,
54
+ "fixes": 0,
55
+ "issues": 0,
56
+ "reason": "done",
57
+ "elapsed": 0.0,
58
+ }
59
+
60
+ with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
61
+ for iteration in range(1, max_iterations + 1):
62
+ stats["iterations"] = iteration
63
+ disp.start_iteration(iteration)
64
+
65
+ # ── Fixer phase ───────────────────────────────────────────────────
66
+ disp.start_phase("fixer")
67
+ fixer_start = time.monotonic()
68
+ try:
69
+ _fixer_output = agent.run(prompt_text, verbose=verbose)
70
+ except AgentError as exc:
71
+ disp.finish_phase(
72
+ "fixer",
73
+ status="error",
74
+ detail=str(exc),
75
+ duration=time.monotonic() - fixer_start,
76
+ success=False,
77
+ )
78
+ stats["reason"] = "agent_error"
79
+ break
80
+
81
+ fixer_duration = time.monotonic() - fixer_start
82
+
83
+ # ── Check for changes ─────────────────────────────────────────────
84
+ try:
85
+ changed = git.has_changes()
86
+ except GitError as exc:
87
+ disp.finish_phase(
88
+ "fixer",
89
+ status="error",
90
+ detail=str(exc),
91
+ duration=fixer_duration,
92
+ success=False,
93
+ )
94
+ stats["reason"] = "git_error"
95
+ break
96
+
97
+ if not changed:
98
+ disp.finish_phase(
99
+ "fixer",
100
+ status="no changes",
101
+ detail="nothing to fix",
102
+ duration=fixer_duration,
103
+ success=True,
104
+ )
105
+ stats["reason"] = "no_changes"
106
+ break
107
+
108
+ # ── Commit ────────────────────────────────────────────────────────
109
+ commit_msg = f"fix(edge): iteration {iteration}"
110
+ if not no_commit:
111
+ try:
112
+ git.commit(commit_msg)
113
+ except GitError as exc:
114
+ disp.finish_phase(
115
+ "fixer",
116
+ status="commit error",
117
+ detail=str(exc),
118
+ duration=fixer_duration,
119
+ success=False,
120
+ )
121
+ stats["reason"] = "git_error"
122
+ break
123
+
124
+ disp.record_fix()
125
+ stats["fixes"] += 1
126
+ disp.finish_phase(
127
+ "fixer",
128
+ status="committed" if not no_commit else "changed",
129
+ detail=commit_msg,
130
+ duration=fixer_duration,
131
+ success=True,
132
+ )
133
+
134
+ # ── Breaker phase ─────────────────────────────────────────────────
135
+ disp.start_phase("breaker")
136
+ breaker_start = time.monotonic()
137
+ try:
138
+ breaker_output = agent.run(break_text, verbose=verbose)
139
+ except AgentError as exc:
140
+ disp.finish_phase(
141
+ "breaker",
142
+ status="error",
143
+ detail=str(exc),
144
+ duration=time.monotonic() - breaker_start,
145
+ success=False,
146
+ )
147
+ stats["reason"] = "agent_error"
148
+ break
149
+
150
+ breaker_duration = time.monotonic() - breaker_start
151
+ signal = signals.parse(breaker_output)
152
+ finding = signals.extract_finding(breaker_output)
153
+
154
+ if signal == "clear":
155
+ disp.finish_phase(
156
+ "breaker",
157
+ status="all clear",
158
+ detail=finding or "no issues found",
159
+ duration=breaker_duration,
160
+ success=True,
161
+ )
162
+ stats["reason"] = "clear"
163
+ break
164
+ else:
165
+ disp.record_issue()
166
+ stats["issues"] += 1
167
+ disp.finish_phase(
168
+ "breaker",
169
+ status="issues",
170
+ detail=finding or "issues found",
171
+ duration=breaker_duration,
172
+ success=False,
173
+ )
174
+ stats["reason"] = "issues"
175
+ # Continue to next iteration
176
+
177
+ else:
178
+ # Loop exhausted without break
179
+ stats["reason"] = "max_iterations"
180
+
181
+ stats["elapsed"] = time.monotonic() - start_time
182
+ disp.print_summary(stats)
etch/prompt.py ADDED
@@ -0,0 +1,75 @@
1
+ """Prompt file loading utilities."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class PromptError(Exception):
7
+ """Raised when a prompt file cannot be loaded or is invalid."""
8
+
9
+
10
+ def load(path: str | Path) -> str:
11
+ """Load and return the content of a prompt file.
12
+
13
+ Args:
14
+ path: Path to the prompt file.
15
+
16
+ Returns:
17
+ File contents as a string.
18
+
19
+ Raises:
20
+ PromptError: If the file does not exist or is empty.
21
+ """
22
+ p = Path(path)
23
+ if not p.exists():
24
+ raise PromptError(f"Prompt file not found: {p}")
25
+ if not p.is_file():
26
+ raise PromptError(f"Prompt path is not a file: {p}")
27
+
28
+ content = p.read_text(encoding="utf-8")
29
+ if not content.strip():
30
+ raise PromptError(f"Prompt file is empty: {p}")
31
+
32
+ return content
33
+
34
+
35
+ def load_break(path: str | Path | None = None) -> str:
36
+ """Load and return the content of BREAK.md.
37
+
38
+ Searches in order:
39
+ 1. The explicit path if provided
40
+ 2. Same directory as the provided path (treating it as ETCH.md location)
41
+ 3. Current working directory
42
+
43
+ Args:
44
+ path: Optional path. If this is ETCH.md, looks for BREAK.md alongside it.
45
+ If this is the BREAK.md path directly, loads it directly.
46
+
47
+ Returns:
48
+ File contents as a string.
49
+
50
+ Raises:
51
+ PromptError: If BREAK.md cannot be found or is empty.
52
+ """
53
+ candidates: list[Path] = []
54
+
55
+ if path is not None:
56
+ p = Path(path)
57
+ # If caller passed BREAK.md directly
58
+ if p.name.upper() == "BREAK.MD":
59
+ candidates.append(p)
60
+ else:
61
+ # Treat path as ETCH.md — look for BREAK.md alongside it
62
+ candidates.append(p.parent / "BREAK.md")
63
+
64
+ # Always fall back to cwd
65
+ candidates.append(Path.cwd() / "BREAK.md")
66
+
67
+ for candidate in candidates:
68
+ if candidate.exists() and candidate.is_file():
69
+ content = candidate.read_text(encoding="utf-8")
70
+ if not content.strip():
71
+ raise PromptError(f"BREAK.md is empty: {candidate}")
72
+ return content
73
+
74
+ searched = ", ".join(str(c) for c in candidates)
75
+ raise PromptError(f"BREAK.md not found. Searched: {searched}")
etch/signals.py ADDED
@@ -0,0 +1,78 @@
1
+ """Signal parsing for breaker agent output."""
2
+
3
+ _TOKEN_CLEAR = "ETCH_ALL_CLEAR"
4
+ _TOKEN_ISSUES = "ETCH_ISSUES_FOUND"
5
+
6
+
7
+ def parse(output: str) -> str:
8
+ """Parse breaker agent output for control tokens.
9
+
10
+ Returns the signal corresponding to whichever token appears first.
11
+ If both appear, the earlier one wins. If neither appears, returns
12
+ "issues" as a fail-safe.
13
+
14
+ Returns:
15
+ "clear" — ETCH_ALL_CLEAR found (and appears before ETCH_ISSUES_FOUND)
16
+ "issues" — ETCH_ISSUES_FOUND found, appears first, or neither found
17
+ """
18
+ if not isinstance(output, str):
19
+ return "issues"
20
+
21
+ clear_pos = output.find(_TOKEN_CLEAR)
22
+ issues_pos = output.find(_TOKEN_ISSUES)
23
+
24
+ if clear_pos == -1 and issues_pos == -1:
25
+ # Fail-safe: no token found → assume issues
26
+ return "issues"
27
+
28
+ if clear_pos == -1:
29
+ return "issues"
30
+
31
+ if issues_pos == -1:
32
+ return "clear"
33
+
34
+ # Both found — whichever appears first wins
35
+ if clear_pos < issues_pos:
36
+ return "clear"
37
+ return "issues"
38
+
39
+
40
+ def extract_finding(output: str) -> str:
41
+ """Extract first meaningful line before the signal token.
42
+
43
+ Returns the first non-empty, non-header line that appears before
44
+ the signal token, or an empty string if nothing useful is found.
45
+ """
46
+ if not isinstance(output, str) or not output.strip():
47
+ return ""
48
+
49
+ # Find the position of either token
50
+ clear_pos = output.find(_TOKEN_CLEAR)
51
+ issues_pos = output.find(_TOKEN_ISSUES)
52
+
53
+ # Determine the cutoff point (use whichever token appears first)
54
+ cutoff = len(output)
55
+ if clear_pos >= 0 and issues_pos >= 0:
56
+ cutoff = min(clear_pos, issues_pos)
57
+ elif clear_pos >= 0:
58
+ cutoff = clear_pos
59
+ elif issues_pos >= 0:
60
+ cutoff = issues_pos
61
+
62
+ text_before = output[:cutoff].strip()
63
+ if not text_before:
64
+ return ""
65
+
66
+ lines = text_before.splitlines()
67
+ for line in reversed(lines):
68
+ stripped = line.strip()
69
+ # Skip empty lines, markdown headers, and separator lines
70
+ if not stripped:
71
+ continue
72
+ if stripped.startswith("#"):
73
+ continue
74
+ if all(c in "-=*_" for c in stripped):
75
+ continue
76
+ return stripped
77
+
78
+ return ""
@@ -0,0 +1,21 @@
1
+ # BREAK — breaker prompt
2
+
3
+ You are an adversarial code reviewer. Your job is to find what the fixer missed.
4
+
5
+ ## Your mission
6
+
7
+ - Review recent changes and the surrounding code
8
+ - Think like someone trying to make this code fail
9
+ - Look for: newly introduced bugs, assumptions the fixer made, edge cases still unhandled, subtle regressions
10
+
11
+ ## Rules
12
+
13
+ 1. DO NOT edit any files — read only
14
+ 2. Report your findings clearly, one per line
15
+ 3. End your output with EXACTLY one of these tokens on its own line:
16
+ - `ETCH_ISSUES_FOUND` — if you found anything worth fixing
17
+ - `ETCH_ALL_CLEAR` — if the code looks solid
18
+
19
+ ## Scope
20
+
21
+ Same scope as the fixer: [edit to match ETCH.md scope]
etch/templates/ETCH.md ADDED
@@ -0,0 +1,29 @@
1
+ # ETCH — fixer prompt
2
+
3
+ You are a surgical code reviewer focused on edge cases and robustness.
4
+
5
+ ## Your mission
6
+
7
+ Scan the codebase for:
8
+ - Unhandled edge cases and boundary conditions
9
+ - Missing null/None/empty checks
10
+ - Unhandled exceptions and error paths
11
+ - Off-by-one errors
12
+ - Race conditions or unsafe concurrent access
13
+ - Missing input validation at system boundaries
14
+
15
+ ## Rules
16
+
17
+ 1. Fix only what you find — do not refactor, rename, or reorganize
18
+ 2. One logical fix per commit (the harness will commit for you)
19
+ 3. Do not add comments explaining what you fixed
20
+ 4. If you find nothing, make no changes
21
+
22
+ ## Scope
23
+
24
+ Focus on: [edit this to narrow your scope, e.g. "src/auth/", "the payment module"]
25
+
26
+ ## Commit format
27
+
28
+ The harness commits automatically. Each commit will be:
29
+ fix(edge): <short description of what was fixed>
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: etch-loop
3
+ Version: 0.1.0
4
+ Summary: Run Claude Code in a fix-break loop until your codebase is clean
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: rich
8
+ Requires-Dist: typer
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # etch-loop
@@ -0,0 +1,14 @@
1
+ etch/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ etch/agent.py,sha256=7TE6VG2sVdQBtGBAPmYtja9z2F61xZgKVpmljoayzY4,2727
3
+ etch/cli.py,sha256=RBK16A7zesQ0xlcnGq5p3uMstxe2aBr4eeIPZpoPtBg,2391
4
+ etch/display.py,sha256=tMyjVpYlw3N0kC-YeumziOrcHHBMh9e_2fCjaVUjr2I,13040
5
+ etch/git.py,sha256=fBZwwQl1ovCt767R4qtogwTpV5BzMM2skefyWRc7v1k,2941
6
+ etch/loop.py,sha256=PgzXJ-pEe0Azg63yEGAkdXBdAyn9mrmFNeRsCJBNKR0,6794
7
+ etch/prompt.py,sha256=qWgSM1-Fmsntn1Jf8Ck5RamaBvDVDv5yrhq6seLBRJM,2158
8
+ etch/signals.py,sha256=0-l1VwDY17QxI_NbqhbT-AR2Ag644pzI-ie0mU-JPqU,2257
9
+ etch/templates/BREAK.md,sha256=oGfEEOUWGKcQRarMN_GbxVViYeDP7b8Lum0-jAkKQEE,682
10
+ etch/templates/ETCH.md,sha256=oQ2FqE7wIbBFFPzNlHSr62odo5Zepbxmb_r48TZOqM0,823
11
+ etch_loop-0.1.0.dist-info/METADATA,sha256=hUpSB4e8u9-Uw94KIp-NtGFvn6gf0V6mnWDf9S_L1kA,316
12
+ etch_loop-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ etch_loop-0.1.0.dist-info/entry_points.txt,sha256=t3DxkZSkD0j3q5NLI54hh-ArdOOven0OUy0dp_fIi_U,38
14
+ etch_loop-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ etch = etch.cli:app