etch-loop 0.4.2__tar.gz → 0.4.4__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.4.2 → etch_loop-0.4.4}/PKG-INFO +1 -1
- {etch_loop-0.4.2 → etch_loop-0.4.4}/pyproject.toml +1 -1
- etch_loop-0.4.4/src/etch/__init__.py +1 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/display.py +2 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/loop.py +38 -67
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/report.py +1 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/signals.py +26 -44
- etch_loop-0.4.2/src/etch/__init__.py +0 -1
- {etch_loop-0.4.2 → etch_loop-0.4.4}/.github/workflows/workflow.yml +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/README.md +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/agent.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/analyze.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/cli.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/git.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/prompt.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/BREAK.md +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/ETCH.md +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/RUN.md +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/SCAN.md +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/__init__.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_git.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_loop.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_prompt.py +0 -0
- {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_signals.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.4"
|
|
@@ -216,6 +216,8 @@ class EtchDisplay:
|
|
|
216
216
|
title = f"[{AMBER}]- stopped (max iterations)[/{AMBER}]"
|
|
217
217
|
elif reason == "no_changes":
|
|
218
218
|
title = f"[{GREEN}]+ clean — fixer found nothing[/{GREEN}]"
|
|
219
|
+
elif reason == "build_failed":
|
|
220
|
+
title = f"[{RED}]x build failed[/{RED}]"
|
|
219
221
|
else:
|
|
220
222
|
title = f"[{FG}]done[/{FG}]"
|
|
221
223
|
|
|
@@ -66,58 +66,11 @@ def run(
|
|
|
66
66
|
}
|
|
67
67
|
last_breaker_signal: str | None = None
|
|
68
68
|
last_breaker_output: str | None = None
|
|
69
|
-
last_runner_output: str | None = None
|
|
70
69
|
iteration_log: list[dict] = []
|
|
70
|
+
final_runner_entry: dict = {}
|
|
71
71
|
|
|
72
72
|
with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
|
|
73
73
|
|
|
74
|
-
# ── Runner helper — called at every clean exit point ──────────────────
|
|
75
|
-
def try_runner(iter_entry: dict) -> str:
|
|
76
|
-
"""Run the runner phase if configured.
|
|
77
|
-
|
|
78
|
-
Returns:
|
|
79
|
-
"skip" — no RUN.md, proceed with clean exit
|
|
80
|
-
"clear" — runner passed, proceed with clean exit
|
|
81
|
-
"issues" — runner failed, continue the loop
|
|
82
|
-
"error" — agent error, break the loop
|
|
83
|
-
"""
|
|
84
|
-
nonlocal last_runner_output
|
|
85
|
-
if not run_text:
|
|
86
|
-
return "skip"
|
|
87
|
-
|
|
88
|
-
disp.start_phase("runner")
|
|
89
|
-
runner_start = time.monotonic()
|
|
90
|
-
try:
|
|
91
|
-
runner_output = agent.run(run_text, verbose=verbose)
|
|
92
|
-
except AgentError as exc:
|
|
93
|
-
disp.finish_phase("runner", status="error", detail=str(exc),
|
|
94
|
-
duration=time.monotonic() - runner_start, success=False)
|
|
95
|
-
return "error"
|
|
96
|
-
|
|
97
|
-
runner_duration = time.monotonic() - runner_start
|
|
98
|
-
runner_signal = signals.parse(runner_output)
|
|
99
|
-
runner_detail = (
|
|
100
|
-
signals.extract_summary(runner_output)
|
|
101
|
-
or signals.extract_finding(runner_output)
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
if runner_signal == "clear":
|
|
105
|
-
disp.finish_phase("runner", status="all clear",
|
|
106
|
-
detail=runner_detail or "build passed",
|
|
107
|
-
duration=runner_duration, success=True)
|
|
108
|
-
iter_entry["runner"] = {"status": "all clear", "detail": runner_detail}
|
|
109
|
-
last_runner_output = None
|
|
110
|
-
return "clear"
|
|
111
|
-
else:
|
|
112
|
-
disp.record_issue()
|
|
113
|
-
stats["issues"] += 1
|
|
114
|
-
disp.finish_phase("runner", status="build failed",
|
|
115
|
-
detail=runner_detail or "build failed",
|
|
116
|
-
duration=runner_duration, success=False)
|
|
117
|
-
iter_entry["runner"] = {"status": "build failed", "detail": runner_detail}
|
|
118
|
-
last_runner_output = runner_output
|
|
119
|
-
return "issues"
|
|
120
|
-
|
|
121
74
|
# ── Main loop ─────────────────────────────────────────────────────────
|
|
122
75
|
for iteration in range(1, max_iterations + 1):
|
|
123
76
|
stats["iterations"] = iteration
|
|
@@ -169,12 +122,6 @@ def run(
|
|
|
169
122
|
f"{last_breaker_output.strip()}\n\n"
|
|
170
123
|
f"Also address these if not already covered above.\n"
|
|
171
124
|
)
|
|
172
|
-
if last_runner_output:
|
|
173
|
-
fixer_prompt += (
|
|
174
|
-
f"\n\n## Build/test failures from previous iteration\n\n"
|
|
175
|
-
f"{last_runner_output.strip()}\n\n"
|
|
176
|
-
f"Fix the underlying code issues causing these failures.\n"
|
|
177
|
-
)
|
|
178
125
|
|
|
179
126
|
# ── Fixer phase ───────────────────────────────────────────────────
|
|
180
127
|
disp.start_phase("fixer")
|
|
@@ -264,19 +211,9 @@ def run(
|
|
|
264
211
|
detail=breaker_detail or "no issues found",
|
|
265
212
|
duration=breaker_duration, success=True)
|
|
266
213
|
iter_entry["breaker"] = {"status": "all clear", "detail": breaker_detail}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
iteration_log.append(iter_entry)
|
|
271
|
-
break
|
|
272
|
-
elif runner_result == "issues":
|
|
273
|
-
stats["reason"] = "issues"
|
|
274
|
-
iteration_log.append(iter_entry)
|
|
275
|
-
continue
|
|
276
|
-
else: # "clear" or "skip"
|
|
277
|
-
stats["reason"] = "clear"
|
|
278
|
-
iteration_log.append(iter_entry)
|
|
279
|
-
break
|
|
214
|
+
stats["reason"] = "clear"
|
|
215
|
+
iteration_log.append(iter_entry)
|
|
216
|
+
break
|
|
280
217
|
else:
|
|
281
218
|
disp.record_issue()
|
|
282
219
|
stats["issues"] += 1
|
|
@@ -290,6 +227,38 @@ def run(
|
|
|
290
227
|
else:
|
|
291
228
|
stats["reason"] = "max_iterations"
|
|
292
229
|
|
|
230
|
+
# ── Runner — final step, only when loop ended cleanly ─────────────────
|
|
231
|
+
if run_text and stats["reason"] in ("clear", "no_changes"):
|
|
232
|
+
disp.start_phase("runner")
|
|
233
|
+
runner_start = time.monotonic()
|
|
234
|
+
try:
|
|
235
|
+
runner_output = agent.run(run_text, verbose=verbose)
|
|
236
|
+
runner_duration = time.monotonic() - runner_start
|
|
237
|
+
runner_signal = signals.parse(runner_output)
|
|
238
|
+
runner_detail = (
|
|
239
|
+
signals.extract_summary(runner_output)
|
|
240
|
+
or signals.extract_finding(runner_output)
|
|
241
|
+
)
|
|
242
|
+
if runner_signal == "clear":
|
|
243
|
+
disp.finish_phase("runner", status="all clear",
|
|
244
|
+
detail=runner_detail or "build passed",
|
|
245
|
+
duration=runner_duration, success=True)
|
|
246
|
+
final_runner_entry = {"status": "all clear", "detail": runner_detail}
|
|
247
|
+
else:
|
|
248
|
+
disp.record_issue()
|
|
249
|
+
stats["issues"] += 1
|
|
250
|
+
disp.finish_phase("runner", status="build failed",
|
|
251
|
+
detail=runner_detail or "build failed",
|
|
252
|
+
duration=runner_duration, success=False)
|
|
253
|
+
final_runner_entry = {"status": "build failed", "detail": runner_detail}
|
|
254
|
+
stats["reason"] = "build_failed"
|
|
255
|
+
except AgentError as exc:
|
|
256
|
+
disp.finish_phase("runner", status="error",
|
|
257
|
+
detail=str(exc),
|
|
258
|
+
duration=time.monotonic() - runner_start, success=False)
|
|
259
|
+
final_runner_entry = {"status": "error", "detail": str(exc)}
|
|
260
|
+
stats["reason"] = "agent_error"
|
|
261
|
+
|
|
293
262
|
stats["elapsed"] = time.monotonic() - start_time
|
|
294
263
|
|
|
295
264
|
# Live panel is fully closed before printing anything below
|
|
@@ -297,6 +266,8 @@ def run(
|
|
|
297
266
|
|
|
298
267
|
# ── Write report ──────────────────────────────────────────────────────────
|
|
299
268
|
try:
|
|
269
|
+
if final_runner_entry:
|
|
270
|
+
iteration_log.append({"n": "runner", "runner": final_runner_entry})
|
|
300
271
|
report_path = report.write(stats, iteration_log, output_dir=prompt_path.parent)
|
|
301
272
|
if not no_git and not no_commit and stats["fixes"] > 0:
|
|
302
273
|
try:
|
|
@@ -6,36 +6,30 @@ _PUNCTUATION_ONLY = set("-=*_`~><|")
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def parse(output: str) -> str:
|
|
9
|
-
"""Parse
|
|
9
|
+
"""Parse agent output for control tokens.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
Tokens must appear alone on their own line (after stripping whitespace
|
|
12
|
+
and backtick wrappers). This prevents false matches when agents quote the
|
|
13
|
+
token strings in explanations like `ETCH_ALL_CLEAR`.
|
|
14
|
+
|
|
15
|
+
The first matching line wins. If neither token is found, returns "issues"
|
|
16
|
+
as a fail-safe.
|
|
14
17
|
|
|
15
18
|
Returns:
|
|
16
|
-
"clear" — ETCH_ALL_CLEAR found
|
|
17
|
-
"issues" — ETCH_ISSUES_FOUND found
|
|
19
|
+
"clear" — ETCH_ALL_CLEAR found on its own line first
|
|
20
|
+
"issues" — ETCH_ISSUES_FOUND found on its own line first, or no token found
|
|
18
21
|
"""
|
|
19
22
|
if not isinstance(output, str):
|
|
20
23
|
return "issues"
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if clear_pos == -1:
|
|
30
|
-
return "issues"
|
|
31
|
-
|
|
32
|
-
if issues_pos == -1:
|
|
33
|
-
return "clear"
|
|
25
|
+
for line in output.splitlines():
|
|
26
|
+
stripped = line.strip().strip("`").strip()
|
|
27
|
+
if stripped == _TOKEN_CLEAR:
|
|
28
|
+
return "clear"
|
|
29
|
+
if stripped == _TOKEN_ISSUES:
|
|
30
|
+
return "issues"
|
|
34
31
|
|
|
35
|
-
#
|
|
36
|
-
if clear_pos < issues_pos:
|
|
37
|
-
return "clear"
|
|
38
|
-
return "issues"
|
|
32
|
+
return "issues" # fail-safe: no token found
|
|
39
33
|
|
|
40
34
|
|
|
41
35
|
def extract_commit_message(output: str, fallback: str) -> str:
|
|
@@ -93,35 +87,23 @@ def extract_summary(output: str) -> str:
|
|
|
93
87
|
|
|
94
88
|
|
|
95
89
|
def extract_finding(output: str) -> str:
|
|
96
|
-
"""Extract
|
|
90
|
+
"""Extract the last meaningful line before the signal token line.
|
|
97
91
|
|
|
98
|
-
|
|
99
|
-
the
|
|
92
|
+
Scans line by line, stops at the first line that IS a token (exact match).
|
|
93
|
+
Returns the last non-empty, non-header line before that point.
|
|
100
94
|
"""
|
|
101
95
|
if not isinstance(output, str) or not output.strip():
|
|
102
96
|
return ""
|
|
103
97
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if clear_pos >= 0 and issues_pos >= 0:
|
|
111
|
-
cutoff = min(clear_pos, issues_pos)
|
|
112
|
-
elif clear_pos >= 0:
|
|
113
|
-
cutoff = clear_pos
|
|
114
|
-
elif issues_pos >= 0:
|
|
115
|
-
cutoff = issues_pos
|
|
116
|
-
|
|
117
|
-
text_before = output[:cutoff].strip()
|
|
118
|
-
if not text_before:
|
|
119
|
-
return ""
|
|
98
|
+
lines_before: list[str] = []
|
|
99
|
+
for line in output.splitlines():
|
|
100
|
+
stripped = line.strip().strip("`").strip()
|
|
101
|
+
if stripped in (_TOKEN_CLEAR, _TOKEN_ISSUES):
|
|
102
|
+
break
|
|
103
|
+
lines_before.append(line)
|
|
120
104
|
|
|
121
|
-
|
|
122
|
-
for line in reversed(lines):
|
|
105
|
+
for line in reversed(lines_before):
|
|
123
106
|
stripped = line.strip().strip("`").strip()
|
|
124
|
-
# Skip empty lines, markdown headers, separator lines, and bare punctuation
|
|
125
107
|
if not stripped:
|
|
126
108
|
continue
|
|
127
109
|
if stripped.startswith("#"):
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.4.2"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|