etch-loop 0.2.1__tar.gz → 0.3.1__tar.gz
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_loop-0.2.1 → etch_loop-0.3.1}/PKG-INFO +1 -1
- {etch_loop-0.2.1 → etch_loop-0.3.1}/pyproject.toml +1 -1
- etch_loop-0.3.1/src/etch/__init__.py +1 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/cli.py +7 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/display.py +46 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/git.py +4 -3
- etch_loop-0.3.1/src/etch/loop.py +239 -0
- etch_loop-0.3.1/src/etch/report.py +89 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/signals.py +19 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/templates/BREAK.md +4 -1
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/templates/ETCH.md +5 -1
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/templates/SCAN.md +4 -1
- etch_loop-0.2.1/src/etch/__init__.py +0 -1
- etch_loop-0.2.1/src/etch/loop.py +0 -261
- {etch_loop-0.2.1 → etch_loop-0.3.1}/.github/workflows/workflow.yml +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/README.md +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/agent.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/analyze.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/src/etch/prompt.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/tests/__init__.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/tests/test_git.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/tests/test_loop.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/tests/test_prompt.py +0 -0
- {etch_loop-0.2.1 → etch_loop-0.3.1}/tests/test_signals.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -72,6 +72,12 @@ def run(
|
|
|
72
72
|
help="Skip git commits after fixer runs.",
|
|
73
73
|
is_flag=True,
|
|
74
74
|
),
|
|
75
|
+
no_git: bool = typer.Option(
|
|
76
|
+
False,
|
|
77
|
+
"--no-git",
|
|
78
|
+
help="Disable all git operations (diff checks and commits).",
|
|
79
|
+
is_flag=True,
|
|
80
|
+
),
|
|
75
81
|
dry_run: bool = typer.Option(
|
|
76
82
|
False,
|
|
77
83
|
"--dry-run",
|
|
@@ -98,6 +104,7 @@ def run(
|
|
|
98
104
|
prompt_path=prompt,
|
|
99
105
|
max_iterations=max_iterations,
|
|
100
106
|
no_commit=no_commit,
|
|
107
|
+
no_git=no_git,
|
|
101
108
|
dry_run=dry_run,
|
|
102
109
|
verbose=verbose,
|
|
103
110
|
focus=focus,
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import threading
|
|
6
6
|
import time
|
|
7
7
|
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
8
9
|
from typing import Any, Callable, TypeVar
|
|
9
10
|
|
|
10
11
|
from rich.columns import Columns
|
|
@@ -450,6 +451,43 @@ def run_with_scan(label: str, fn: Callable[[], _T]) -> _T:
|
|
|
450
451
|
return result[0]
|
|
451
452
|
|
|
452
453
|
|
|
454
|
+
def print_summary(stats: dict[str, Any]) -> None:
|
|
455
|
+
"""Standalone summary panel, called after the Live context exits."""
|
|
456
|
+
reason = stats.get("reason", "done")
|
|
457
|
+
elapsed = stats.get("elapsed", 0.0)
|
|
458
|
+
iterations = stats.get("iterations", 0)
|
|
459
|
+
fixes = stats.get("fixes", 0)
|
|
460
|
+
issues = stats.get("issues", 0)
|
|
461
|
+
elapsed_str = _format_elapsed(elapsed)
|
|
462
|
+
|
|
463
|
+
if reason == "clear":
|
|
464
|
+
title = f"[{GREEN}]+ all clear[/{GREEN}]"
|
|
465
|
+
elif reason == "interrupted":
|
|
466
|
+
title = f"[{AMBER}]- interrupted[/{AMBER}]"
|
|
467
|
+
elif reason == "max_iterations":
|
|
468
|
+
title = f"[{AMBER}]- stopped (max iterations)[/{AMBER}]"
|
|
469
|
+
elif reason == "no_changes":
|
|
470
|
+
title = f"[{GREEN}]+ clean — fixer found nothing[/{GREEN}]"
|
|
471
|
+
else:
|
|
472
|
+
title = f"[{FG}]done[/{FG}]"
|
|
473
|
+
|
|
474
|
+
body = (
|
|
475
|
+
f"[{DIM}]iterations[/{DIM}] [{FG}]{iterations}[/{FG}] "
|
|
476
|
+
f"[{DIM}]fixes[/{DIM}] [{FG}]{fixes}[/{FG}] "
|
|
477
|
+
f"[{DIM}]breaker issues[/{DIM}] [{FG}]{issues}[/{FG}] "
|
|
478
|
+
f"[{DIM}]{elapsed_str} elapsed[/{DIM}]"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
_console.print(
|
|
482
|
+
Panel(
|
|
483
|
+
body,
|
|
484
|
+
title=title,
|
|
485
|
+
border_style=Style(color=BORDER),
|
|
486
|
+
style=Style(bgcolor=BG),
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
453
491
|
def print_dry_run(prompt_text: str) -> None:
|
|
454
492
|
_console.print(
|
|
455
493
|
Panel(
|
|
@@ -497,6 +535,14 @@ def print_init_skip(filename: str) -> None:
|
|
|
497
535
|
)
|
|
498
536
|
|
|
499
537
|
|
|
538
|
+
def print_report_saved(path: Path) -> None:
|
|
539
|
+
try:
|
|
540
|
+
display_path = path.relative_to(Path.cwd())
|
|
541
|
+
except ValueError:
|
|
542
|
+
display_path = path
|
|
543
|
+
_console.print(f"[{DIM}]- report -> {display_path}[/{DIM}]")
|
|
544
|
+
|
|
545
|
+
|
|
500
546
|
# ── Utilities ─────────────────────────────────────────────────────────────────
|
|
501
547
|
|
|
502
548
|
|
|
@@ -54,7 +54,7 @@ def has_changes() -> bool:
|
|
|
54
54
|
return bool(status.stdout.strip())
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
def commit(message: str) -> None:
|
|
57
|
+
def commit(message: str, paths: list[str] | None = None) -> None:
|
|
58
58
|
"""Stage all changes and create a commit.
|
|
59
59
|
|
|
60
60
|
Args:
|
|
@@ -66,10 +66,11 @@ def commit(message: str) -> None:
|
|
|
66
66
|
if not message or not message.strip():
|
|
67
67
|
raise GitError("Commit message must not be empty.")
|
|
68
68
|
|
|
69
|
-
# Stage all changes
|
|
69
|
+
# Stage all changes (or specific paths)
|
|
70
|
+
add_cmd = ["git", "add"] + (paths if paths else ["-A"])
|
|
70
71
|
try:
|
|
71
72
|
add_result = subprocess.run(
|
|
72
|
-
|
|
73
|
+
add_cmd,
|
|
73
74
|
capture_output=True,
|
|
74
75
|
text=True,
|
|
75
76
|
)
|
|
@@ -0,0 +1,239 @@
|
|
|
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, report, signals
|
|
9
|
+
from etch.agent import AgentError
|
|
10
|
+
from etch.git import GitError
|
|
11
|
+
from etch.prompt import PromptError, load_scan
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run(
|
|
15
|
+
prompt_path: str | Path,
|
|
16
|
+
max_iterations: int = 20,
|
|
17
|
+
no_commit: bool = False,
|
|
18
|
+
no_git: bool = False,
|
|
19
|
+
dry_run: bool = False,
|
|
20
|
+
verbose: bool = False,
|
|
21
|
+
focus: str | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Run the scan-fix-break loop."""
|
|
24
|
+
prompt_path = Path(prompt_path)
|
|
25
|
+
|
|
26
|
+
# ── Load prompts ──────────────────────────────────────────────────────────
|
|
27
|
+
try:
|
|
28
|
+
prompt_text = prompt.load(prompt_path)
|
|
29
|
+
except PromptError as exc:
|
|
30
|
+
display.print_error(str(exc))
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
if focus:
|
|
34
|
+
prompt_text += f"\n\n## User focus\n\nConcentrate specifically on: {focus}\n"
|
|
35
|
+
|
|
36
|
+
if dry_run:
|
|
37
|
+
display.print_dry_run(prompt_text)
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
scan_text = prompt.load_scan(prompt_path)
|
|
42
|
+
except PromptError as exc:
|
|
43
|
+
display.print_error(str(exc))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
break_text = prompt.load_break(prompt_path)
|
|
48
|
+
except PromptError as exc:
|
|
49
|
+
display.print_error(str(exc))
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if focus:
|
|
53
|
+
scan_text += f"\n\n## User focus\n\nConcentrate on: {focus}\n"
|
|
54
|
+
break_text += f"\n\n## User focus\n\nConcentrate your adversarial review on: {focus}\n"
|
|
55
|
+
|
|
56
|
+
start_time = time.monotonic()
|
|
57
|
+
stats: dict = {
|
|
58
|
+
"iterations": 0,
|
|
59
|
+
"fixes": 0,
|
|
60
|
+
"issues": 0,
|
|
61
|
+
"reason": "done",
|
|
62
|
+
"elapsed": 0.0,
|
|
63
|
+
}
|
|
64
|
+
last_breaker_signal: str | None = None
|
|
65
|
+
last_breaker_output: str | None = None
|
|
66
|
+
iteration_log: list[dict] = []
|
|
67
|
+
|
|
68
|
+
with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
|
|
69
|
+
for iteration in range(1, max_iterations + 1):
|
|
70
|
+
stats["iterations"] = iteration
|
|
71
|
+
disp.start_iteration(iteration)
|
|
72
|
+
iter_entry: dict = {"n": iteration}
|
|
73
|
+
|
|
74
|
+
# ── Scanner phase ─────────────────────────────────────────────────
|
|
75
|
+
disp.start_phase("scanner")
|
|
76
|
+
scanner_start = time.monotonic()
|
|
77
|
+
try:
|
|
78
|
+
scanner_output = agent.run(scan_text, verbose=verbose)
|
|
79
|
+
except AgentError as exc:
|
|
80
|
+
disp.finish_phase("scanner", status="error", detail=str(exc),
|
|
81
|
+
duration=time.monotonic() - scanner_start, success=False)
|
|
82
|
+
stats["reason"] = "agent_error"
|
|
83
|
+
iteration_log.append(iter_entry)
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
scanner_duration = time.monotonic() - scanner_start
|
|
87
|
+
scanner_signal = signals.parse(scanner_output)
|
|
88
|
+
scanner_detail = (
|
|
89
|
+
signals.extract_summary(scanner_output)
|
|
90
|
+
or signals.extract_finding(scanner_output)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if scanner_signal == "clear":
|
|
94
|
+
disp.finish_phase("scanner", status="all clear",
|
|
95
|
+
detail=scanner_detail or "nothing to fix",
|
|
96
|
+
duration=scanner_duration, success=True)
|
|
97
|
+
iter_entry["scanner"] = {"status": "all clear", "detail": scanner_detail}
|
|
98
|
+
stats["reason"] = "no_changes"
|
|
99
|
+
iteration_log.append(iter_entry)
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
disp.finish_phase("scanner", status="issues found",
|
|
103
|
+
detail=scanner_detail or "issues found",
|
|
104
|
+
duration=scanner_duration, success=False)
|
|
105
|
+
iter_entry["scanner"] = {"status": "issues found", "detail": scanner_detail}
|
|
106
|
+
|
|
107
|
+
# ── Build fixer prompt ────────────────────────────────────────────
|
|
108
|
+
fixer_prompt = prompt_text
|
|
109
|
+
fixer_prompt += (
|
|
110
|
+
f"\n\n## Scanner findings\n\n{scanner_output.strip()}\n\n"
|
|
111
|
+
f"Fix these specific issues.\n"
|
|
112
|
+
)
|
|
113
|
+
if last_breaker_output:
|
|
114
|
+
fixer_prompt += (
|
|
115
|
+
f"\n\n## Breaker findings from previous iteration\n\n"
|
|
116
|
+
f"{last_breaker_output.strip()}\n\n"
|
|
117
|
+
f"Also address these if not already covered above.\n"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# ── Fixer phase ───────────────────────────────────────────────────
|
|
121
|
+
disp.start_phase("fixer")
|
|
122
|
+
fixer_start = time.monotonic()
|
|
123
|
+
try:
|
|
124
|
+
_fixer_output = agent.run(fixer_prompt, verbose=verbose)
|
|
125
|
+
except AgentError as exc:
|
|
126
|
+
disp.finish_phase("fixer", status="error", detail=str(exc),
|
|
127
|
+
duration=time.monotonic() - fixer_start, success=False)
|
|
128
|
+
stats["reason"] = "agent_error"
|
|
129
|
+
iteration_log.append(iter_entry)
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
fixer_duration = time.monotonic() - fixer_start
|
|
133
|
+
|
|
134
|
+
# ── Check for changes (skipped when no_git) ───────────────────────
|
|
135
|
+
if not no_git:
|
|
136
|
+
try:
|
|
137
|
+
changed = git.has_changes()
|
|
138
|
+
except GitError as exc:
|
|
139
|
+
disp.finish_phase("fixer", status="error", detail=str(exc),
|
|
140
|
+
duration=fixer_duration, success=False)
|
|
141
|
+
stats["reason"] = "git_error"
|
|
142
|
+
iteration_log.append(iter_entry)
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
if not changed:
|
|
146
|
+
disp.finish_phase("fixer", status="no changes", detail="nothing to fix",
|
|
147
|
+
duration=fixer_duration, success=True)
|
|
148
|
+
iter_entry["fixer"] = {"status": "no changes", "detail": "nothing to fix"}
|
|
149
|
+
if last_breaker_signal != "issues":
|
|
150
|
+
stats["reason"] = "no_changes"
|
|
151
|
+
iteration_log.append(iter_entry)
|
|
152
|
+
break
|
|
153
|
+
iteration_log.append(iter_entry)
|
|
154
|
+
# Fall through to breaker
|
|
155
|
+
|
|
156
|
+
# ── Commit ────────────────────────────────────────────────────────
|
|
157
|
+
fixer_summary = (
|
|
158
|
+
signals.extract_summary(_fixer_output)
|
|
159
|
+
or signals.extract_commit_message(_fixer_output, fallback="")
|
|
160
|
+
)
|
|
161
|
+
commit_msg = signals.extract_commit_message(
|
|
162
|
+
_fixer_output, fallback=f"fix(edge): iteration {iteration}"
|
|
163
|
+
)
|
|
164
|
+
if not no_git and not no_commit:
|
|
165
|
+
try:
|
|
166
|
+
git.commit(commit_msg)
|
|
167
|
+
except GitError as exc:
|
|
168
|
+
disp.finish_phase("fixer", status="commit error", detail=str(exc),
|
|
169
|
+
duration=fixer_duration, success=False)
|
|
170
|
+
stats["reason"] = "git_error"
|
|
171
|
+
iteration_log.append(iter_entry)
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
disp.record_fix()
|
|
175
|
+
stats["fixes"] += 1
|
|
176
|
+
status_label = "changed" if (no_git or no_commit) else "committed"
|
|
177
|
+
fixer_detail = fixer_summary or commit_msg
|
|
178
|
+
disp.finish_phase("fixer", status=status_label, detail=fixer_detail,
|
|
179
|
+
duration=fixer_duration, success=True)
|
|
180
|
+
iter_entry["fixer"] = {"status": status_label, "detail": fixer_detail}
|
|
181
|
+
|
|
182
|
+
# ── Breaker phase ─────────────────────────────────────────────────
|
|
183
|
+
disp.start_phase("breaker")
|
|
184
|
+
breaker_start = time.monotonic()
|
|
185
|
+
try:
|
|
186
|
+
breaker_output = agent.run(break_text, verbose=verbose)
|
|
187
|
+
except AgentError as exc:
|
|
188
|
+
disp.finish_phase("breaker", status="error", detail=str(exc),
|
|
189
|
+
duration=time.monotonic() - breaker_start, success=False)
|
|
190
|
+
stats["reason"] = "agent_error"
|
|
191
|
+
iteration_log.append(iter_entry)
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
breaker_duration = time.monotonic() - breaker_start
|
|
195
|
+
signal = signals.parse(breaker_output)
|
|
196
|
+
last_breaker_signal = signal
|
|
197
|
+
last_breaker_output = breaker_output if signal == "issues" else None
|
|
198
|
+
breaker_detail = (
|
|
199
|
+
signals.extract_summary(breaker_output)
|
|
200
|
+
or signals.extract_finding(breaker_output)
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if signal == "clear":
|
|
204
|
+
disp.finish_phase("breaker", status="all clear",
|
|
205
|
+
detail=breaker_detail or "no issues found",
|
|
206
|
+
duration=breaker_duration, success=True)
|
|
207
|
+
iter_entry["breaker"] = {"status": "all clear", "detail": breaker_detail}
|
|
208
|
+
stats["reason"] = "clear"
|
|
209
|
+
iteration_log.append(iter_entry)
|
|
210
|
+
break
|
|
211
|
+
else:
|
|
212
|
+
disp.record_issue()
|
|
213
|
+
stats["issues"] += 1
|
|
214
|
+
disp.finish_phase("breaker", status="issues",
|
|
215
|
+
detail=breaker_detail or "issues found",
|
|
216
|
+
duration=breaker_duration, success=False)
|
|
217
|
+
iter_entry["breaker"] = {"status": "issues", "detail": breaker_detail}
|
|
218
|
+
stats["reason"] = "issues"
|
|
219
|
+
iteration_log.append(iter_entry)
|
|
220
|
+
|
|
221
|
+
else:
|
|
222
|
+
stats["reason"] = "max_iterations"
|
|
223
|
+
|
|
224
|
+
stats["elapsed"] = time.monotonic() - start_time
|
|
225
|
+
|
|
226
|
+
# Live panel is fully closed before printing anything below
|
|
227
|
+
display.print_summary(stats)
|
|
228
|
+
|
|
229
|
+
# ── Write report ──────────────────────────────────────────────────────────
|
|
230
|
+
try:
|
|
231
|
+
report_path = report.write(stats, iteration_log, output_dir=prompt_path.parent)
|
|
232
|
+
if not no_git and not no_commit and stats["fixes"] > 0:
|
|
233
|
+
try:
|
|
234
|
+
git.commit(f"etch: add run report {report_path.name}", paths=[str(report_path)])
|
|
235
|
+
except GitError:
|
|
236
|
+
pass # report write is best-effort
|
|
237
|
+
display.print_report_saved(report_path)
|
|
238
|
+
except Exception:
|
|
239
|
+
pass # report is best-effort, never fail the run over it
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Run report generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write(
|
|
11
|
+
stats: dict[str, Any],
|
|
12
|
+
iterations: list[dict],
|
|
13
|
+
output_dir: Path | None = None,
|
|
14
|
+
) -> Path:
|
|
15
|
+
"""Write a markdown report for a completed etch run.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
stats: Final stats dict from the loop.
|
|
19
|
+
iterations: List of per-iteration dicts with scanner/fixer/breaker findings.
|
|
20
|
+
output_dir: Directory to write the report. Defaults to cwd.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Path to the written report file.
|
|
24
|
+
"""
|
|
25
|
+
output_dir = output_dir or Path.cwd()
|
|
26
|
+
reports_dir = output_dir / "etch-reports"
|
|
27
|
+
reports_dir.mkdir(exist_ok=True)
|
|
28
|
+
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M")
|
|
29
|
+
path = reports_dir / f"etch-report-{timestamp}.md"
|
|
30
|
+
|
|
31
|
+
lines: list[str] = []
|
|
32
|
+
lines.append(f"# etch run — {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}\n")
|
|
33
|
+
|
|
34
|
+
reason = stats.get("reason", "done")
|
|
35
|
+
elapsed = _fmt_elapsed(stats.get("elapsed", 0.0))
|
|
36
|
+
lines.append(f"**outcome:** {_reason_label(reason)} ")
|
|
37
|
+
lines.append(f"**iterations:** {stats.get('iterations', 0)} ")
|
|
38
|
+
lines.append(f"**fixes:** {stats.get('fixes', 0)} ")
|
|
39
|
+
lines.append(f"**breaker issues:** {stats.get('issues', 0)} ")
|
|
40
|
+
lines.append(f"**elapsed:** {elapsed}\n")
|
|
41
|
+
|
|
42
|
+
for entry in iterations:
|
|
43
|
+
n = entry.get("n", "?")
|
|
44
|
+
lines.append(f"---\n\n## iteration {n}\n")
|
|
45
|
+
|
|
46
|
+
scanner = entry.get("scanner")
|
|
47
|
+
if scanner:
|
|
48
|
+
status = scanner.get("status", "")
|
|
49
|
+
detail = scanner.get("detail", "")
|
|
50
|
+
lines.append(f"**scanner** — {status}")
|
|
51
|
+
if detail:
|
|
52
|
+
lines.append(f"\n> {detail}\n")
|
|
53
|
+
|
|
54
|
+
fixer = entry.get("fixer")
|
|
55
|
+
if fixer:
|
|
56
|
+
status = fixer.get("status", "")
|
|
57
|
+
detail = fixer.get("detail", "")
|
|
58
|
+
lines.append(f"**fixer** — {status}")
|
|
59
|
+
if detail:
|
|
60
|
+
lines.append(f"\n> {detail}\n")
|
|
61
|
+
|
|
62
|
+
breaker = entry.get("breaker")
|
|
63
|
+
if breaker:
|
|
64
|
+
status = breaker.get("status", "")
|
|
65
|
+
detail = breaker.get("detail", "")
|
|
66
|
+
lines.append(f"**breaker** — {status}")
|
|
67
|
+
if detail:
|
|
68
|
+
lines.append(f"\n> {detail}\n")
|
|
69
|
+
|
|
70
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _reason_label(reason: str) -> str:
|
|
75
|
+
return {
|
|
76
|
+
"clear": "all clear",
|
|
77
|
+
"no_changes": "clean — nothing to fix",
|
|
78
|
+
"max_iterations": "stopped — max iterations reached",
|
|
79
|
+
"interrupted": "interrupted",
|
|
80
|
+
"agent_error": "stopped — agent error",
|
|
81
|
+
"git_error": "stopped — git error",
|
|
82
|
+
}.get(reason, reason)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fmt_elapsed(seconds: float) -> str:
|
|
86
|
+
seconds = max(0.0, seconds)
|
|
87
|
+
mins = int(seconds // 60)
|
|
88
|
+
secs = int(seconds % 60)
|
|
89
|
+
return f"{mins}m {secs}s" if mins else f"{secs}s"
|
|
@@ -66,6 +66,8 @@ def extract_commit_message(output: str, fallback: str) -> str:
|
|
|
66
66
|
continue
|
|
67
67
|
# Trim to a reasonable length and strip trailing punctuation
|
|
68
68
|
msg = stripped[:72].rstrip(".,;:")
|
|
69
|
+
if not msg:
|
|
70
|
+
continue
|
|
69
71
|
if not msg.lower().startswith("fix"):
|
|
70
72
|
msg = f"fix(edge): {msg[0].lower()}{msg[1:]}"
|
|
71
73
|
return msg
|
|
@@ -73,6 +75,23 @@ def extract_commit_message(output: str, fallback: str) -> str:
|
|
|
73
75
|
return fallback
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
def extract_summary(output: str) -> str:
|
|
79
|
+
"""Extract the ETCH_SUMMARY line written by an agent.
|
|
80
|
+
|
|
81
|
+
Agents are prompted to write a line like:
|
|
82
|
+
ETCH_SUMMARY: fixed 3 null-guard issues in auth.py
|
|
83
|
+
|
|
84
|
+
Returns the summary text, or empty string if not found.
|
|
85
|
+
"""
|
|
86
|
+
if not isinstance(output, str):
|
|
87
|
+
return ""
|
|
88
|
+
for line in output.splitlines():
|
|
89
|
+
stripped = line.strip()
|
|
90
|
+
if stripped.startswith("ETCH_SUMMARY:"):
|
|
91
|
+
return stripped[len("ETCH_SUMMARY:"):].strip()
|
|
92
|
+
return ""
|
|
93
|
+
|
|
94
|
+
|
|
76
95
|
def extract_finding(output: str) -> str:
|
|
77
96
|
"""Extract first meaningful line before the signal token.
|
|
78
97
|
|
|
@@ -19,7 +19,10 @@ Be adversarial — think like someone actively trying to make this code fail.
|
|
|
19
19
|
|
|
20
20
|
1. DO NOT edit any files — read only
|
|
21
21
|
2. Report your findings clearly, one per line
|
|
22
|
-
3.
|
|
22
|
+
3. Before the signal token, write one line starting with `ETCH_SUMMARY:` summarising what you found:
|
|
23
|
+
- `ETCH_SUMMARY: 2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67`
|
|
24
|
+
- `ETCH_SUMMARY: no issues found — code looks solid`
|
|
25
|
+
4. End your output with EXACTLY one of these tokens on its own line:
|
|
23
26
|
- `ETCH_ISSUES_FOUND` — if you found anything worth fixing
|
|
24
27
|
- `ETCH_ALL_CLEAR` — if the code looks solid
|
|
25
28
|
|
|
@@ -23,7 +23,11 @@ Scan the codebase for:
|
|
|
23
23
|
|
|
24
24
|
Focus on: [edit this to narrow your scope, e.g. "src/auth/", "the payment module"]
|
|
25
25
|
|
|
26
|
-
##
|
|
26
|
+
## Output format
|
|
27
|
+
|
|
28
|
+
After making your changes, write one line at the end of your output:
|
|
29
|
+
ETCH_SUMMARY: <concise summary, e.g. "fixed 3 issues — added null guards in auth.py, guarded empty input in parser.py">
|
|
30
|
+
ETCH_SUMMARY: no changes — nothing to fix
|
|
27
31
|
|
|
28
32
|
The harness commits automatically. Each commit will be:
|
|
29
33
|
fix(edge): <short description of what was fixed>
|
|
@@ -23,7 +23,10 @@ For each issue, include the file path, line number (if known), and a one-line de
|
|
|
23
23
|
5. List each confirmed issue on its own line, e.g.:
|
|
24
24
|
- src/auth.py:42 — crashes with empty token string (no guard)
|
|
25
25
|
- src/api.js:108 — unhandled promise rejection will silently fail
|
|
26
|
-
6.
|
|
26
|
+
6. Before the signal token, write one line starting with `ETCH_SUMMARY:` summarising what you found in plain English:
|
|
27
|
+
- `ETCH_SUMMARY: found 3 issues — null dereference in auth.py:42, off-by-one in parser.py:88, unhandled OSError in git.py:31`
|
|
28
|
+
- `ETCH_SUMMARY: no confirmed bugs found`
|
|
29
|
+
7. End your output with EXACTLY one of these tokens on its own line:
|
|
27
30
|
- `ETCH_ISSUES_FOUND` — if you found confirmed bugs worth fixing
|
|
28
31
|
- `ETCH_ALL_CLEAR` — if the code looks solid or you found nothing certain
|
|
29
32
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.1"
|
etch_loop-0.2.1/src/etch/loop.py
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
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, load_scan
|
|
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
|
-
focus: str | None = None,
|
|
21
|
-
) -> None:
|
|
22
|
-
"""Run the fix-break loop.
|
|
23
|
-
|
|
24
|
-
Args:
|
|
25
|
-
prompt_path: Path to ETCH.md (fixer prompt).
|
|
26
|
-
max_iterations: Maximum number of fix-break cycles to run.
|
|
27
|
-
no_commit: If True, skip git commits after fixer runs.
|
|
28
|
-
dry_run: If True, print the prompt and exit without running.
|
|
29
|
-
verbose: If True, stream agent output to the terminal.
|
|
30
|
-
"""
|
|
31
|
-
prompt_path = Path(prompt_path)
|
|
32
|
-
|
|
33
|
-
# ── Load fixer prompt ──────────────────────────────────────────────────────
|
|
34
|
-
try:
|
|
35
|
-
prompt_text = prompt.load(prompt_path)
|
|
36
|
-
except PromptError as exc:
|
|
37
|
-
display.print_error(str(exc))
|
|
38
|
-
return
|
|
39
|
-
|
|
40
|
-
if focus:
|
|
41
|
-
prompt_text += f"\n\n## User focus\n\nConcentrate specifically on: {focus}\n"
|
|
42
|
-
|
|
43
|
-
# ── Dry run ───────────────────────────────────────────────────────────────
|
|
44
|
-
if dry_run:
|
|
45
|
-
display.print_dry_run(prompt_text)
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
# ── Load scanner + breaker prompts early to fail fast ────────────────────
|
|
49
|
-
try:
|
|
50
|
-
scan_text = prompt.load_scan(prompt_path)
|
|
51
|
-
except PromptError as exc:
|
|
52
|
-
display.print_error(str(exc))
|
|
53
|
-
return
|
|
54
|
-
|
|
55
|
-
try:
|
|
56
|
-
break_text = prompt.load_break(prompt_path)
|
|
57
|
-
except PromptError as exc:
|
|
58
|
-
display.print_error(str(exc))
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
if focus:
|
|
62
|
-
scan_text += f"\n\n## User focus\n\nConcentrate on: {focus}\n"
|
|
63
|
-
break_text += f"\n\n## User focus\n\nConcentrate your adversarial review on: {focus}\n"
|
|
64
|
-
|
|
65
|
-
start_time = time.monotonic()
|
|
66
|
-
stats: dict = {
|
|
67
|
-
"iterations": 0,
|
|
68
|
-
"fixes": 0,
|
|
69
|
-
"issues": 0,
|
|
70
|
-
"reason": "done",
|
|
71
|
-
"elapsed": 0.0,
|
|
72
|
-
}
|
|
73
|
-
last_breaker_signal: str | None = None # None = breaker hasn't run yet
|
|
74
|
-
last_breaker_output: str | None = None
|
|
75
|
-
|
|
76
|
-
with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
|
|
77
|
-
for iteration in range(1, max_iterations + 1):
|
|
78
|
-
stats["iterations"] = iteration
|
|
79
|
-
disp.start_iteration(iteration)
|
|
80
|
-
|
|
81
|
-
# ── Scanner phase ─────────────────────────────────────────────────
|
|
82
|
-
disp.start_phase("scanner")
|
|
83
|
-
scanner_start = time.monotonic()
|
|
84
|
-
try:
|
|
85
|
-
scanner_output = agent.run(scan_text, verbose=verbose)
|
|
86
|
-
except AgentError as exc:
|
|
87
|
-
disp.finish_phase(
|
|
88
|
-
"scanner",
|
|
89
|
-
status="error",
|
|
90
|
-
detail=str(exc),
|
|
91
|
-
duration=time.monotonic() - scanner_start,
|
|
92
|
-
success=False,
|
|
93
|
-
)
|
|
94
|
-
stats["reason"] = "agent_error"
|
|
95
|
-
break
|
|
96
|
-
|
|
97
|
-
scanner_duration = time.monotonic() - scanner_start
|
|
98
|
-
scanner_signal = signals.parse(scanner_output)
|
|
99
|
-
scanner_finding = signals.extract_finding(scanner_output)
|
|
100
|
-
|
|
101
|
-
if scanner_signal == "clear":
|
|
102
|
-
disp.finish_phase(
|
|
103
|
-
"scanner",
|
|
104
|
-
status="all clear",
|
|
105
|
-
detail=scanner_finding or "nothing to fix",
|
|
106
|
-
duration=scanner_duration,
|
|
107
|
-
success=True,
|
|
108
|
-
)
|
|
109
|
-
stats["reason"] = "no_changes"
|
|
110
|
-
break
|
|
111
|
-
|
|
112
|
-
disp.finish_phase(
|
|
113
|
-
"scanner",
|
|
114
|
-
status="issues found",
|
|
115
|
-
detail=scanner_finding or "issues found",
|
|
116
|
-
duration=scanner_duration,
|
|
117
|
-
success=False,
|
|
118
|
-
)
|
|
119
|
-
|
|
120
|
-
# ── Build fixer prompt with scanner + breaker findings ─────────────
|
|
121
|
-
fixer_prompt = prompt_text
|
|
122
|
-
fixer_prompt += (
|
|
123
|
-
f"\n\n## Scanner findings\n\n"
|
|
124
|
-
f"{scanner_output.strip()}\n\n"
|
|
125
|
-
f"Fix these specific issues.\n"
|
|
126
|
-
)
|
|
127
|
-
if last_breaker_output:
|
|
128
|
-
fixer_prompt += (
|
|
129
|
-
f"\n\n## Breaker findings from previous iteration\n\n"
|
|
130
|
-
f"{last_breaker_output.strip()}\n\n"
|
|
131
|
-
f"Also address these if not already covered above.\n"
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
# ── Fixer phase ───────────────────────────────────────────────────
|
|
135
|
-
disp.start_phase("fixer")
|
|
136
|
-
fixer_start = time.monotonic()
|
|
137
|
-
try:
|
|
138
|
-
_fixer_output = agent.run(fixer_prompt, verbose=verbose)
|
|
139
|
-
except AgentError as exc:
|
|
140
|
-
disp.finish_phase(
|
|
141
|
-
"fixer",
|
|
142
|
-
status="error",
|
|
143
|
-
detail=str(exc),
|
|
144
|
-
duration=time.monotonic() - fixer_start,
|
|
145
|
-
success=False,
|
|
146
|
-
)
|
|
147
|
-
stats["reason"] = "agent_error"
|
|
148
|
-
break
|
|
149
|
-
|
|
150
|
-
fixer_duration = time.monotonic() - fixer_start
|
|
151
|
-
|
|
152
|
-
# ── Check for changes ─────────────────────────────────────────────
|
|
153
|
-
try:
|
|
154
|
-
changed = git.has_changes()
|
|
155
|
-
except GitError as exc:
|
|
156
|
-
disp.finish_phase(
|
|
157
|
-
"fixer",
|
|
158
|
-
status="error",
|
|
159
|
-
detail=str(exc),
|
|
160
|
-
duration=fixer_duration,
|
|
161
|
-
success=False,
|
|
162
|
-
)
|
|
163
|
-
stats["reason"] = "git_error"
|
|
164
|
-
break
|
|
165
|
-
|
|
166
|
-
if not changed:
|
|
167
|
-
disp.finish_phase(
|
|
168
|
-
"fixer",
|
|
169
|
-
status="no changes",
|
|
170
|
-
detail="nothing to fix",
|
|
171
|
-
duration=fixer_duration,
|
|
172
|
-
success=True,
|
|
173
|
-
)
|
|
174
|
-
# If the breaker has never run (first iteration with no diff),
|
|
175
|
-
# stop immediately — nothing was ever changed, nothing to challenge.
|
|
176
|
-
# If the breaker previously found issues, run it once more to
|
|
177
|
-
# confirm whether those issues are still present or now resolved.
|
|
178
|
-
if last_breaker_signal != "issues":
|
|
179
|
-
stats["reason"] = "no_changes"
|
|
180
|
-
break
|
|
181
|
-
# Fall through to run the breaker one final time
|
|
182
|
-
|
|
183
|
-
# ── Commit ────────────────────────────────────────────────────────
|
|
184
|
-
commit_msg = signals.extract_commit_message(
|
|
185
|
-
_fixer_output, fallback=f"fix(edge): iteration {iteration}"
|
|
186
|
-
)
|
|
187
|
-
if not no_commit:
|
|
188
|
-
try:
|
|
189
|
-
git.commit(commit_msg)
|
|
190
|
-
except GitError as exc:
|
|
191
|
-
disp.finish_phase(
|
|
192
|
-
"fixer",
|
|
193
|
-
status="commit error",
|
|
194
|
-
detail=str(exc),
|
|
195
|
-
duration=fixer_duration,
|
|
196
|
-
success=False,
|
|
197
|
-
)
|
|
198
|
-
stats["reason"] = "git_error"
|
|
199
|
-
break
|
|
200
|
-
|
|
201
|
-
disp.record_fix()
|
|
202
|
-
stats["fixes"] += 1
|
|
203
|
-
disp.finish_phase(
|
|
204
|
-
"fixer",
|
|
205
|
-
status="committed" if not no_commit else "changed",
|
|
206
|
-
detail=commit_msg,
|
|
207
|
-
duration=fixer_duration,
|
|
208
|
-
success=True,
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
# ── Breaker phase ─────────────────────────────────────────────────
|
|
212
|
-
disp.start_phase("breaker")
|
|
213
|
-
breaker_start = time.monotonic()
|
|
214
|
-
try:
|
|
215
|
-
breaker_output = agent.run(break_text, verbose=verbose)
|
|
216
|
-
except AgentError as exc:
|
|
217
|
-
disp.finish_phase(
|
|
218
|
-
"breaker",
|
|
219
|
-
status="error",
|
|
220
|
-
detail=str(exc),
|
|
221
|
-
duration=time.monotonic() - breaker_start,
|
|
222
|
-
success=False,
|
|
223
|
-
)
|
|
224
|
-
stats["reason"] = "agent_error"
|
|
225
|
-
break
|
|
226
|
-
|
|
227
|
-
breaker_duration = time.monotonic() - breaker_start
|
|
228
|
-
signal = signals.parse(breaker_output)
|
|
229
|
-
last_breaker_signal = signal
|
|
230
|
-
last_breaker_output = breaker_output if signal == "issues" else None
|
|
231
|
-
finding = signals.extract_finding(breaker_output)
|
|
232
|
-
|
|
233
|
-
if signal == "clear":
|
|
234
|
-
disp.finish_phase(
|
|
235
|
-
"breaker",
|
|
236
|
-
status="all clear",
|
|
237
|
-
detail=finding or "no issues found",
|
|
238
|
-
duration=breaker_duration,
|
|
239
|
-
success=True,
|
|
240
|
-
)
|
|
241
|
-
stats["reason"] = "clear"
|
|
242
|
-
break
|
|
243
|
-
else:
|
|
244
|
-
disp.record_issue()
|
|
245
|
-
stats["issues"] += 1
|
|
246
|
-
disp.finish_phase(
|
|
247
|
-
"breaker",
|
|
248
|
-
status="issues",
|
|
249
|
-
detail=finding or "issues found",
|
|
250
|
-
duration=breaker_duration,
|
|
251
|
-
success=False,
|
|
252
|
-
)
|
|
253
|
-
stats["reason"] = "issues"
|
|
254
|
-
# Continue to next iteration
|
|
255
|
-
|
|
256
|
-
else:
|
|
257
|
-
# Loop exhausted without break
|
|
258
|
-
stats["reason"] = "max_iterations"
|
|
259
|
-
|
|
260
|
-
stats["elapsed"] = time.monotonic() - start_time
|
|
261
|
-
disp.print_summary(stats)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|