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 +1 -0
- etch/agent.py +91 -0
- etch/cli.py +90 -0
- etch/display.py +389 -0
- etch/git.py +101 -0
- etch/loop.py +182 -0
- etch/prompt.py +75 -0
- etch/signals.py +78 -0
- etch/templates/BREAK.md +21 -0
- etch/templates/ETCH.md +29 -0
- etch_loop-0.1.0.dist-info/METADATA +13 -0
- etch_loop-0.1.0.dist-info/RECORD +14 -0
- etch_loop-0.1.0.dist-info/WHEEL +4 -0
- etch_loop-0.1.0.dist-info/entry_points.txt +2 -0
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 ""
|
etch/templates/BREAK.md
ADDED
|
@@ -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,,
|