etch-loop 0.4.8__tar.gz → 0.5.0__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.8 → etch_loop-0.5.0}/PKG-INFO +1 -1
  2. {etch_loop-0.4.8 → etch_loop-0.5.0}/pyproject.toml +1 -1
  3. etch_loop-0.5.0/src/etch/__init__.py +1 -0
  4. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/agent.py +13 -10
  5. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/analyze.py +3 -2
  6. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/display.py +2 -2
  7. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/loop.py +47 -26
  8. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/templates/RUN.md +3 -2
  9. etch_loop-0.4.8/src/etch/__init__.py +0 -1
  10. {etch_loop-0.4.8 → etch_loop-0.5.0}/.github/workflows/workflow.yml +0 -0
  11. {etch_loop-0.4.8 → etch_loop-0.5.0}/README.md +0 -0
  12. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/cli.py +0 -0
  13. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/git.py +0 -0
  14. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/prompt.py +0 -0
  15. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/report.py +0 -0
  16. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/signals.py +0 -0
  17. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/templates/BREAK.md +0 -0
  18. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/templates/ETCH.md +0 -0
  19. {etch_loop-0.4.8 → etch_loop-0.5.0}/src/etch/templates/SCAN.md +0 -0
  20. {etch_loop-0.4.8 → etch_loop-0.5.0}/tests/__init__.py +0 -0
  21. {etch_loop-0.4.8 → etch_loop-0.5.0}/tests/test_git.py +0 -0
  22. {etch_loop-0.4.8 → etch_loop-0.5.0}/tests/test_loop.py +0 -0
  23. {etch_loop-0.4.8 → etch_loop-0.5.0}/tests/test_prompt.py +0 -0
  24. {etch_loop-0.4.8 → etch_loop-0.5.0}/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.8
3
+ Version: 0.5.0
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.8"
3
+ version = "0.5.0"
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.5.0"
@@ -13,6 +13,7 @@ def run(
13
13
  prompt: str,
14
14
  verbose: bool = False,
15
15
  tick_callback: Callable[[str], None] | None = None,
16
+ timeout: int = 300,
16
17
  ) -> str:
17
18
  """Run the Claude agent with the given prompt piped to stdin.
18
19
 
@@ -58,7 +59,7 @@ def run(
58
59
  try:
59
60
  process.stdin.write(prompt)
60
61
  process.stdin.close()
61
- except BrokenPipeError as exc:
62
+ except OSError as exc:
62
63
  stdin_exc.append(exc)
63
64
 
64
65
  stdin_writer = threading.Thread(target=write_stdin, daemon=True)
@@ -71,11 +72,8 @@ def run(
71
72
  process.kill()
72
73
  raise AgentError(f"Failed to write prompt to claude stdin: {stdin_exc[0]}") from stdin_exc[0]
73
74
 
74
- if process.stdout is None:
75
- process.kill()
76
- raise AgentError("claude subprocess has no stdout")
77
-
78
75
  output_lines: list[str] = []
76
+ stderr_lines: list[str] = []
79
77
  lock = threading.Lock()
80
78
 
81
79
  def read_stdout() -> None:
@@ -87,23 +85,28 @@ def run(
87
85
  if tick_callback is not None:
88
86
  tick_callback(line)
89
87
 
88
+ def read_stderr() -> None:
89
+ for line in process.stderr:
90
+ stderr_lines.append(line)
91
+
90
92
  reader = threading.Thread(target=read_stdout, daemon=True)
93
+ stderr_reader = threading.Thread(target=read_stderr, daemon=True)
91
94
  reader.start()
92
- reader.join(timeout=300)
95
+ stderr_reader.start()
96
+ reader.join(timeout=timeout)
93
97
  if reader.is_alive():
94
98
  process.kill()
95
99
  raise AgentError("claude subprocess timed out (output reader still running)")
96
100
 
101
+ stderr_reader.join(timeout=10)
102
+
97
103
  try:
98
104
  process.wait(timeout=10)
99
105
  except subprocess.TimeoutExpired:
100
106
  process.kill()
101
107
  raise AgentError("claude subprocess timed out waiting for exit")
102
108
 
103
- # Capture stderr for error reporting
104
- stderr_output = ""
105
- if process.stderr:
106
- stderr_output = process.stderr.read().strip()
109
+ stderr_output = "".join(stderr_lines).strip()
107
110
 
108
111
  if process.returncode != 0:
109
112
  detail = stderr_output or "(no stderr)"
@@ -299,10 +299,11 @@ You are a test engineer. The fixer has just made changes. Your job is to write t
299
299
  1. You MAY edit test files — that is your job
300
300
  2. Do NOT touch production code — only tests
301
301
  3. If tests fail because of flawed test logic, fix the test and re-run before reporting
302
- 4. When done, write your summary in this exact format it appears directly in the terminal:
302
+ 4. **After tests pass, delete every test file you created during this session**leave no temporary test files behind
303
+ 5. When done, write your summary in this exact format — it appears directly in the terminal:
303
304
  `<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
304
305
  `<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
305
- 5. End with EXACTLY one of these on its own line:
306
+ 6. End with EXACTLY one of these on its own line:
306
307
  `ETCH_ALL_CLEAR` — if all tests pass
307
308
  `ETCH_ISSUES_FOUND` — if tests reveal a bug in production code
308
309
  """
@@ -376,8 +376,8 @@ class InitDisplay:
376
376
 
377
377
  table = Table.grid(padding=(0, 1))
378
378
  table.add_column(width=2) # symbol
379
- table.add_column(width=12) # label
380
- table.add_column() # detail / scanbar
379
+ table.add_column(width=20) # label
380
+ table.add_column() # scanbar (only used while scanning)
381
381
 
382
382
  for sym, color, text in lines:
383
383
  table.add_row(
@@ -50,7 +50,11 @@ def run(
50
50
  return
51
51
 
52
52
  # Runner is optional — None means the phase is skipped
53
- run_text = prompt.load_run(prompt_path)
53
+ try:
54
+ run_text = prompt.load_run(prompt_path)
55
+ except PromptError as exc:
56
+ display.print_error(str(exc))
57
+ return
54
58
 
55
59
  if focus:
56
60
  scan_text += f"\n\n## User focus\n\nConcentrate on: {focus}\n"
@@ -105,6 +109,15 @@ def run(
105
109
  iteration_log.append(iter_entry)
106
110
  break
107
111
 
112
+ if scanner_signal == "empty":
113
+ disp.finish_phase("scanner", status="no signal",
114
+ detail="agent produced no output token",
115
+ duration=scanner_duration, success=False)
116
+ iter_entry["scanner"] = {"status": "no signal", "detail": "agent produced no output token"}
117
+ stats["reason"] = "agent_error"
118
+ iteration_log.append(iter_entry)
119
+ break
120
+
108
121
  disp.finish_phase("scanner", status="issues found",
109
122
  detail=scanner_detail or "issues found",
110
123
  duration=scanner_duration, success=False)
@@ -156,34 +169,34 @@ def run(
156
169
  stats["reason"] = "no_changes"
157
170
  iteration_log.append(iter_entry)
158
171
  break
159
- iteration_log.append(iter_entry)
160
172
  # Fall through to breaker
161
173
 
162
174
  # ── Commit ────────────────────────────────────────────────────────
163
- fixer_summary = (
164
- signals.extract_summary(_fixer_output)
165
- or signals.extract_commit_message(_fixer_output, fallback="")
166
- )
167
- commit_msg = signals.extract_commit_message(
168
- _fixer_output, fallback=f"fix(edge): iteration {iteration}"
169
- )
170
- if not no_git and not no_commit:
171
- try:
172
- git.commit(commit_msg)
173
- except GitError as exc:
174
- disp.finish_phase("fixer", status="commit error", detail=str(exc),
175
- duration=fixer_duration, success=False)
176
- stats["reason"] = "git_error"
177
- iteration_log.append(iter_entry)
178
- break
175
+ if no_git or changed:
176
+ fixer_summary = (
177
+ signals.extract_summary(_fixer_output)
178
+ or signals.extract_commit_message(_fixer_output, fallback="")
179
+ )
180
+ commit_msg = signals.extract_commit_message(
181
+ _fixer_output, fallback=f"fix(edge): iteration {iteration}"
182
+ )
183
+ if not no_git and not no_commit:
184
+ try:
185
+ git.commit(commit_msg)
186
+ except GitError as exc:
187
+ disp.finish_phase("fixer", status="commit error", detail=str(exc),
188
+ duration=fixer_duration, success=False)
189
+ stats["reason"] = "git_error"
190
+ iteration_log.append(iter_entry)
191
+ break
179
192
 
180
- disp.record_fix()
181
- stats["fixes"] += 1
182
- status_label = "changed" if (no_git or no_commit) else "committed"
183
- fixer_detail = fixer_summary or commit_msg
184
- disp.finish_phase("fixer", status=status_label, detail=fixer_detail,
185
- duration=fixer_duration, success=True)
186
- iter_entry["fixer"] = {"status": status_label, "detail": fixer_detail}
193
+ disp.record_fix()
194
+ stats["fixes"] += 1
195
+ status_label = "changed" if (no_git or no_commit) else "committed"
196
+ fixer_detail = fixer_summary or commit_msg
197
+ disp.finish_phase("fixer", status=status_label, detail=fixer_detail,
198
+ duration=fixer_duration, success=True)
199
+ iter_entry["fixer"] = {"status": status_label, "detail": fixer_detail}
187
200
 
188
201
  # ── Breaker phase ─────────────────────────────────────────────────
189
202
  disp.start_phase("breaker")
@@ -214,6 +227,14 @@ def run(
214
227
  stats["reason"] = "clear"
215
228
  iteration_log.append(iter_entry)
216
229
  break
230
+ elif signal == "empty":
231
+ disp.finish_phase("breaker", status="no signal",
232
+ detail="agent produced no output token",
233
+ duration=breaker_duration, success=False)
234
+ iter_entry["breaker"] = {"status": "no signal", "detail": "agent produced no output token"}
235
+ stats["reason"] = "agent_error"
236
+ iteration_log.append(iter_entry)
237
+ break
217
238
  else:
218
239
  disp.record_issue()
219
240
  stats["issues"] += 1
@@ -232,7 +253,7 @@ def run(
232
253
  disp.start_phase("runner")
233
254
  runner_start = time.monotonic()
234
255
  try:
235
- runner_output = agent.run(run_text, verbose=verbose)
256
+ runner_output = agent.run(run_text, verbose=verbose, timeout=600)
236
257
  runner_duration = time.monotonic() - runner_start
237
258
  runner_signal = signals.parse(runner_output)
238
259
  runner_detail = (
@@ -21,9 +21,10 @@ You are a test engineer. The fixer has just made changes. Your job is to write t
21
21
  1. You MAY edit test files — that is your job
22
22
  2. Do NOT touch production code — only tests
23
23
  3. If tests fail because of flawed test logic, fix the test and re-run before reporting
24
- 4. When done, write your summary in this exact format it appears directly in the terminal:
24
+ 4. **After tests pass, delete every test file you created during this session**leave no temporary test files behind
25
+ 5. When done, write your summary in this exact format — it appears directly in the terminal:
25
26
  `<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
26
27
  `<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
27
- 5. End with EXACTLY one of these on its own line:
28
+ 6. End with EXACTLY one of these on its own line:
28
29
  `ETCH_ALL_CLEAR` — if all tests pass
29
30
  `ETCH_ISSUES_FOUND` — if tests reveal a bug in production code
@@ -1 +0,0 @@
1
- __version__ = "0.4.8"
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