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.
Files changed (24) hide show
  1. {etch_loop-0.4.2 → etch_loop-0.4.4}/PKG-INFO +1 -1
  2. {etch_loop-0.4.2 → etch_loop-0.4.4}/pyproject.toml +1 -1
  3. etch_loop-0.4.4/src/etch/__init__.py +1 -0
  4. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/display.py +2 -0
  5. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/loop.py +38 -67
  6. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/report.py +1 -0
  7. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/signals.py +26 -44
  8. etch_loop-0.4.2/src/etch/__init__.py +0 -1
  9. {etch_loop-0.4.2 → etch_loop-0.4.4}/.github/workflows/workflow.yml +0 -0
  10. {etch_loop-0.4.2 → etch_loop-0.4.4}/README.md +0 -0
  11. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/agent.py +0 -0
  12. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/analyze.py +0 -0
  13. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/cli.py +0 -0
  14. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/git.py +0 -0
  15. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/prompt.py +0 -0
  16. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/BREAK.md +0 -0
  17. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/ETCH.md +0 -0
  18. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/RUN.md +0 -0
  19. {etch_loop-0.4.2 → etch_loop-0.4.4}/src/etch/templates/SCAN.md +0 -0
  20. {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/__init__.py +0 -0
  21. {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_git.py +0 -0
  22. {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_loop.py +0 -0
  23. {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_prompt.py +0 -0
  24. {etch_loop-0.4.2 → etch_loop-0.4.4}/tests/test_signals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etch-loop
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Run Claude Code in a fix-break loop until your codebase is clean
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "etch-loop"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  requires-python = ">=3.11"
5
5
  description = "Run Claude Code in a fix-break loop until your codebase is clean"
6
6
  readme = "README.md"
@@ -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
- runner_result = try_runner(iter_entry)
268
- if runner_result == "error":
269
- stats["reason"] = "agent_error"
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:
@@ -87,6 +87,7 @@ def _reason_label(reason: str) -> str:
87
87
  "interrupted": "interrupted",
88
88
  "agent_error": "stopped — agent error",
89
89
  "git_error": "stopped — git error",
90
+ "build_failed": "stopped — build/tests failed",
90
91
  }.get(reason, reason)
91
92
 
92
93
 
@@ -6,36 +6,30 @@ _PUNCTUATION_ONLY = set("-=*_`~><|")
6
6
 
7
7
 
8
8
  def parse(output: str) -> str:
9
- """Parse breaker agent output for control tokens.
9
+ """Parse agent output for control tokens.
10
10
 
11
- Returns the signal corresponding to whichever token appears first.
12
- If both appear, the earlier one wins. If neither appears, returns
13
- "issues" as a fail-safe.
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 (and appears before ETCH_ISSUES_FOUND)
17
- "issues" — ETCH_ISSUES_FOUND found, appears first, or neither 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
- clear_pos = output.find(_TOKEN_CLEAR)
23
- issues_pos = output.find(_TOKEN_ISSUES)
24
-
25
- if clear_pos == -1 and issues_pos == -1:
26
- # Fail-safe: no token found → assume issues
27
- return "issues"
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
- # Both found whichever appears first wins
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 first meaningful line before the signal token.
90
+ """Extract the last meaningful line before the signal token line.
97
91
 
98
- Returns the first non-empty, non-header line that appears before
99
- the signal token, or an empty string if nothing useful is found.
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
- # Find the position of either token
105
- clear_pos = output.find(_TOKEN_CLEAR)
106
- issues_pos = output.find(_TOKEN_ISSUES)
107
-
108
- # Determine the cutoff point (use whichever token appears first)
109
- cutoff = len(output)
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
- lines = text_before.splitlines()
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