etch-loop 0.3.1__tar.gz → 0.4.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.
Files changed (24) hide show
  1. {etch_loop-0.3.1 → etch_loop-0.4.1}/PKG-INFO +1 -1
  2. {etch_loop-0.3.1 → etch_loop-0.4.1}/pyproject.toml +1 -1
  3. etch_loop-0.4.1/src/etch/__init__.py +1 -0
  4. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/agent.py +32 -9
  5. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/analyze.py +85 -7
  6. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/cli.py +7 -4
  7. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/git.py +2 -2
  8. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/loop.py +98 -9
  9. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/prompt.py +29 -0
  10. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/report.py +8 -0
  11. etch_loop-0.4.1/src/etch/templates/RUN.md +20 -0
  12. etch_loop-0.3.1/src/etch/__init__.py +0 -1
  13. {etch_loop-0.3.1 → etch_loop-0.4.1}/.github/workflows/workflow.yml +0 -0
  14. {etch_loop-0.3.1 → etch_loop-0.4.1}/README.md +0 -0
  15. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/display.py +0 -0
  16. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/signals.py +0 -0
  17. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/templates/BREAK.md +0 -0
  18. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/templates/ETCH.md +0 -0
  19. {etch_loop-0.3.1 → etch_loop-0.4.1}/src/etch/templates/SCAN.md +0 -0
  20. {etch_loop-0.3.1 → etch_loop-0.4.1}/tests/__init__.py +0 -0
  21. {etch_loop-0.3.1 → etch_loop-0.4.1}/tests/test_git.py +0 -0
  22. {etch_loop-0.3.1 → etch_loop-0.4.1}/tests/test_loop.py +0 -0
  23. {etch_loop-0.3.1 → etch_loop-0.4.1}/tests/test_prompt.py +0 -0
  24. {etch_loop-0.3.1 → etch_loop-0.4.1}/tests/test_signals.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etch-loop
3
- Version: 0.3.1
3
+ Version: 0.4.1
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.3.1"
3
+ version = "0.4.1"
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.1"
@@ -51,18 +51,34 @@ def run(
51
51
  except OSError as exc:
52
52
  raise AgentError(f"Failed to launch claude: {exc}") from exc
53
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
54
+ # Write prompt to stdin and close it — run in thread to avoid blocking on full pipe buffer
55
+ stdin_exc: list[Exception] = []
56
+
57
+ def write_stdin() -> None:
58
+ try:
59
+ process.stdin.write(prompt)
60
+ process.stdin.close()
61
+ except BrokenPipeError as exc:
62
+ stdin_exc.append(exc)
63
+
64
+ stdin_writer = threading.Thread(target=write_stdin, daemon=True)
65
+ stdin_writer.start()
66
+ stdin_writer.join(timeout=30)
67
+ if stdin_writer.is_alive():
68
+ process.kill()
69
+ raise AgentError("Timed out writing prompt to claude stdin")
70
+ if stdin_exc:
71
+ process.kill()
72
+ raise AgentError(f"Failed to write prompt to claude stdin: {stdin_exc[0]}") from stdin_exc[0]
73
+
74
+ if process.stdout is None:
75
+ process.kill()
76
+ raise AgentError("claude subprocess has no stdout")
60
77
 
61
78
  output_lines: list[str] = []
62
79
  lock = threading.Lock()
63
80
 
64
81
  def read_stdout() -> None:
65
- assert process.stdout is not None
66
82
  for line in process.stdout:
67
83
  with lock:
68
84
  output_lines.append(line)
@@ -73,9 +89,16 @@ def run(
73
89
 
74
90
  reader = threading.Thread(target=read_stdout, daemon=True)
75
91
  reader.start()
76
- reader.join()
92
+ reader.join(timeout=300)
93
+ if reader.is_alive():
94
+ process.kill()
95
+ raise AgentError("claude subprocess timed out (output reader still running)")
77
96
 
78
- process.wait()
97
+ try:
98
+ process.wait(timeout=10)
99
+ except subprocess.TimeoutExpired:
100
+ process.kill()
101
+ raise AgentError("claude subprocess timed out waiting for exit")
79
102
 
80
103
  # Capture stderr for error reporting
81
104
  stderr_output = ""
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import subprocess
6
7
  from collections import Counter
7
8
  from pathlib import Path
@@ -107,12 +108,14 @@ def analyze(root: Path | None = None) -> dict:
107
108
  "framework": framework,
108
109
  "total_files": total,
109
110
  "is_git": (root / ".git").exists(),
111
+ "root": root,
110
112
  }
111
113
 
112
114
 
113
115
  def build_init_prompt(info: dict) -> str:
114
116
  """Build the Claude prompt used during etch init to analyze the codebase."""
115
- file_tree = "\n".join(f" {f}" for f in _list_files(Path.cwd())[:60])
117
+ root = info.get("root", Path.cwd())
118
+ file_tree = "\n".join(f" {f}" for f in _list_files(root)[:60])
116
119
  if not file_tree:
117
120
  file_tree = " (no tracked files)"
118
121
 
@@ -257,6 +260,78 @@ Be adversarial — think like someone actively trying to make this code fail.
257
260
  """
258
261
 
259
262
 
263
+ def build_run_md(info: dict) -> str:
264
+ """Generate a tailored RUN.md based on detected build system."""
265
+ root = info.get("root", Path.cwd())
266
+ commands = _detect_run_commands(root)
267
+
268
+ if commands:
269
+ cmd_list = "\n".join(f"- `{cmd}`" for cmd in commands)
270
+ else:
271
+ cmd_list = "- (detect and run the appropriate build/test command for this project)"
272
+
273
+ return f"""# RUN — build and test validation
274
+
275
+ You are a build validator. The fixer has made changes. Your job is to run the project's build and test suite to confirm everything still works.
276
+
277
+ ## Commands to run
278
+
279
+ {cmd_list}
280
+
281
+ ## Rules
282
+
283
+ 1. Run each command and observe the output
284
+ 2. If ALL commands pass:
285
+ - Write `ETCH_SUMMARY: <e.g. "all 47 tests passed">`
286
+ - Write `ETCH_ALL_CLEAR`
287
+ 3. If ANY command fails:
288
+ - Write `ETCH_SUMMARY: <what failed, e.g. "3 tests failed in test_auth.py — TypeError on line 42">`
289
+ - Include the relevant error output so the fixer can diagnose it
290
+ - Write `ETCH_ISSUES_FOUND`
291
+
292
+ Do not fix anything — only run and report.
293
+ """
294
+
295
+
296
+ def _detect_run_commands(root: Path) -> list[str]:
297
+ """Detect build/test commands from project files."""
298
+ commands: list[str] = []
299
+
300
+ if (root / "pyproject.toml").exists() or (root / "setup.py").exists():
301
+ commands.append("python -m pytest")
302
+
303
+ if (root / "package.json").exists():
304
+ try:
305
+ pkg = json.loads((root / "package.json").read_text(encoding="utf-8"))
306
+ scripts = pkg.get("scripts", {})
307
+ if "build" in scripts:
308
+ commands.append("npm run build")
309
+ if "test" in scripts:
310
+ commands.append("npm test")
311
+ except (OSError, json.JSONDecodeError):
312
+ commands.append("npm test")
313
+
314
+ if (root / "Cargo.toml").exists():
315
+ commands.append("cargo test")
316
+
317
+ if (root / "go.mod").exists():
318
+ commands.append("go test ./...")
319
+
320
+ if (root / "Gemfile").exists():
321
+ commands.append("bundle exec rspec")
322
+
323
+ if (root / "mix.exs").exists():
324
+ commands.append("mix test")
325
+
326
+ if (root / "pom.xml").exists():
327
+ commands.append("mvn test -q")
328
+
329
+ if not commands and (root / "Makefile").exists():
330
+ commands.append("make test")
331
+
332
+ return commands
333
+
334
+
260
335
  def _format_scope(info: dict) -> str:
261
336
  lines = []
262
337
  if info["source_dirs"]:
@@ -288,12 +363,15 @@ def _list_files(root: Path) -> list[str]:
288
363
 
289
364
  # Fallback: walk filesystem
290
365
  files = []
291
- for p in root.rglob("*"):
292
- if p.is_file() and not any(part in _SKIP_DIRS for part in p.parts):
293
- try:
294
- files.append(str(p.relative_to(root)))
295
- except ValueError:
296
- pass
366
+ try:
367
+ for p in root.rglob("*"):
368
+ if p.is_file() and not any(part in _SKIP_DIRS for part in p.parts):
369
+ try:
370
+ files.append(str(p.relative_to(root)))
371
+ except ValueError:
372
+ pass
373
+ except OSError:
374
+ pass
297
375
  return files
298
376
 
299
377
 
@@ -21,6 +21,8 @@ app = typer.Typer(
21
21
  def init() -> None:
22
22
  """Analyze the codebase with Claude and write tailored SCAN.md, ETCH.md, BREAK.md."""
23
23
  root = Path.cwd()
24
+ etch_dir = root / "etch-loop"
25
+ etch_dir.mkdir(exist_ok=True)
24
26
  info = analyze.analyze(root)
25
27
  init_prompt = analyze.build_init_prompt(info)
26
28
 
@@ -35,9 +37,10 @@ def init() -> None:
35
37
  disp.add_line(display.SYM_NEUTRAL, display.DIM, f"falling back to static analysis ({exc})")
36
38
 
37
39
  for dest, content, label in [
38
- (root / "SCAN.md", analyze.build_scan_md(info, agent_scope), "SCAN.md"),
39
- (root / "ETCH.md", analyze.build_etch_md(info, agent_scope), "ETCH.md"),
40
- (root / "BREAK.md", analyze.build_break_md(info, agent_scope), "BREAK.md"),
40
+ (etch_dir / "SCAN.md", analyze.build_scan_md(info, agent_scope), "etch-loop/SCAN.md"),
41
+ (etch_dir / "ETCH.md", analyze.build_etch_md(info, agent_scope), "etch-loop/ETCH.md"),
42
+ (etch_dir / "BREAK.md", analyze.build_break_md(info, agent_scope), "etch-loop/BREAK.md"),
43
+ (etch_dir / "RUN.md", analyze.build_run_md(info), "etch-loop/RUN.md"),
41
44
  ]:
42
45
  if dest.exists():
43
46
  disp.add_line(display.SYM_NEUTRAL, display.DIM, f"{label} already exists, skipping")
@@ -53,7 +56,7 @@ def run(
53
56
  help="Optional focus description, e.g. 'the auth module' or 'error handling in payments'.",
54
57
  ),
55
58
  prompt: str = typer.Option(
56
- "./ETCH.md",
59
+ "./etch-loop/ETCH.md",
57
60
  "--prompt",
58
61
  help="Path to the fixer prompt file (ETCH.md).",
59
62
  show_default=True,
@@ -48,8 +48,8 @@ def has_changes() -> bool:
48
48
  raise GitError(f"Failed to run git status: {exc}") from exc
49
49
 
50
50
  if status.returncode != 0:
51
- stderr = result.stderr.decode(errors="replace").strip()
52
- raise GitError(f"git diff failed (exit {result.returncode}): {stderr}")
51
+ stderr = status.stderr.strip()
52
+ raise GitError(f"git status failed (exit {status.returncode}): {stderr}")
53
53
 
54
54
  return bool(status.stdout.strip())
55
55
 
@@ -49,6 +49,9 @@ def run(
49
49
  display.print_error(str(exc))
50
50
  return
51
51
 
52
+ # Runner is optional — None means the phase is skipped
53
+ run_text = prompt.load_run(prompt_path)
54
+
52
55
  if focus:
53
56
  scan_text += f"\n\n## User focus\n\nConcentrate on: {focus}\n"
54
57
  break_text += f"\n\n## User focus\n\nConcentrate your adversarial review on: {focus}\n"
@@ -63,9 +66,59 @@ def run(
63
66
  }
64
67
  last_breaker_signal: str | None = None
65
68
  last_breaker_output: str | None = None
69
+ last_runner_output: str | None = None
66
70
  iteration_log: list[dict] = []
67
71
 
68
72
  with display.EtchDisplay(target=str(prompt_path.parent)) as disp:
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
+ # ── Main loop ─────────────────────────────────────────────────────────
69
122
  for iteration in range(1, max_iterations + 1):
70
123
  stats["iterations"] = iteration
71
124
  disp.start_iteration(iteration)
@@ -95,9 +148,19 @@ def run(
95
148
  detail=scanner_detail or "nothing to fix",
96
149
  duration=scanner_duration, success=True)
97
150
  iter_entry["scanner"] = {"status": "all clear", "detail": scanner_detail}
98
- stats["reason"] = "no_changes"
99
- iteration_log.append(iter_entry)
100
- break
151
+ runner_result = try_runner(iter_entry)
152
+ if runner_result == "error":
153
+ stats["reason"] = "agent_error"
154
+ iteration_log.append(iter_entry)
155
+ break
156
+ elif runner_result == "issues":
157
+ stats["reason"] = "issues"
158
+ iteration_log.append(iter_entry)
159
+ continue
160
+ else: # "clear" or "skip"
161
+ stats["reason"] = "no_changes"
162
+ iteration_log.append(iter_entry)
163
+ break
101
164
 
102
165
  disp.finish_phase("scanner", status="issues found",
103
166
  detail=scanner_detail or "issues found",
@@ -116,6 +179,12 @@ def run(
116
179
  f"{last_breaker_output.strip()}\n\n"
117
180
  f"Also address these if not already covered above.\n"
118
181
  )
182
+ if last_runner_output:
183
+ fixer_prompt += (
184
+ f"\n\n## Build/test failures from previous iteration\n\n"
185
+ f"{last_runner_output.strip()}\n\n"
186
+ f"Fix the underlying code issues causing these failures.\n"
187
+ )
119
188
 
120
189
  # ── Fixer phase ───────────────────────────────────────────────────
121
190
  disp.start_phase("fixer")
@@ -147,9 +216,19 @@ def run(
147
216
  duration=fixer_duration, success=True)
148
217
  iter_entry["fixer"] = {"status": "no changes", "detail": "nothing to fix"}
149
218
  if last_breaker_signal != "issues":
150
- stats["reason"] = "no_changes"
151
- iteration_log.append(iter_entry)
152
- break
219
+ runner_result = try_runner(iter_entry)
220
+ if runner_result == "error":
221
+ stats["reason"] = "agent_error"
222
+ iteration_log.append(iter_entry)
223
+ break
224
+ elif runner_result == "issues":
225
+ stats["reason"] = "issues"
226
+ iteration_log.append(iter_entry)
227
+ continue
228
+ else: # "clear" or "skip"
229
+ stats["reason"] = "no_changes"
230
+ iteration_log.append(iter_entry)
231
+ break
153
232
  iteration_log.append(iter_entry)
154
233
  # Fall through to breaker
155
234
 
@@ -205,9 +284,19 @@ def run(
205
284
  detail=breaker_detail or "no issues found",
206
285
  duration=breaker_duration, success=True)
207
286
  iter_entry["breaker"] = {"status": "all clear", "detail": breaker_detail}
208
- stats["reason"] = "clear"
209
- iteration_log.append(iter_entry)
210
- break
287
+ runner_result = try_runner(iter_entry)
288
+ if runner_result == "error":
289
+ stats["reason"] = "agent_error"
290
+ iteration_log.append(iter_entry)
291
+ break
292
+ elif runner_result == "issues":
293
+ stats["reason"] = "issues"
294
+ iteration_log.append(iter_entry)
295
+ continue
296
+ else: # "clear" or "skip"
297
+ stats["reason"] = "clear"
298
+ iteration_log.append(iter_entry)
299
+ break
211
300
  else:
212
301
  disp.record_issue()
213
302
  stats["issues"] += 1
@@ -75,6 +75,35 @@ def load_break(path: str | Path | None = None) -> str:
75
75
  raise PromptError(f"BREAK.md not found. Searched: {searched}")
76
76
 
77
77
 
78
+ def load_run(path: str | Path | None = None) -> str | None:
79
+ """Load RUN.md if it exists. Returns None if not found — runner phase is optional.
80
+
81
+ Args:
82
+ path: Optional path. If this is ETCH.md, looks for RUN.md alongside it.
83
+
84
+ Returns:
85
+ File contents as a string, or None if RUN.md is not present.
86
+ """
87
+ candidates: list[Path] = []
88
+
89
+ if path is not None:
90
+ p = Path(path)
91
+ if p.name.upper() == "RUN.MD":
92
+ candidates.append(p)
93
+ else:
94
+ candidates.append(p.parent / "RUN.md")
95
+
96
+ candidates.append(Path.cwd() / "RUN.md")
97
+
98
+ for candidate in candidates:
99
+ if candidate.exists() and candidate.is_file():
100
+ content = candidate.read_text(encoding="utf-8")
101
+ if content.strip():
102
+ return content
103
+
104
+ return None # Optional phase — no error if absent
105
+
106
+
78
107
  def load_scan(path: str | Path | None = None) -> str:
79
108
  """Load and return the content of SCAN.md.
80
109
 
@@ -67,6 +67,14 @@ def write(
67
67
  if detail:
68
68
  lines.append(f"\n> {detail}\n")
69
69
 
70
+ runner = entry.get("runner")
71
+ if runner:
72
+ status = runner.get("status", "")
73
+ detail = runner.get("detail", "")
74
+ lines.append(f"**runner** — {status}")
75
+ if detail:
76
+ lines.append(f"\n> {detail}\n")
77
+
70
78
  path.write_text("\n".join(lines), encoding="utf-8")
71
79
  return path
72
80
 
@@ -0,0 +1,20 @@
1
+ # RUN — build and test validation
2
+
3
+ You are a build validator. The fixer has made changes. Your job is to run the project's build and test suite to confirm everything still works.
4
+
5
+ ## Commands to run
6
+
7
+ [configured by etch init]
8
+
9
+ ## Rules
10
+
11
+ 1. Run each command and observe the output
12
+ 2. If ALL commands pass:
13
+ - Write `ETCH_SUMMARY: <e.g. "all 47 tests passed">`
14
+ - Write `ETCH_ALL_CLEAR`
15
+ 3. If ANY command fails:
16
+ - Write `ETCH_SUMMARY: <what failed, e.g. "3 tests failed in test_auth.py — TypeError on line 42">`
17
+ - Include the relevant error output so the fixer can diagnose it
18
+ - Write `ETCH_ISSUES_FOUND`
19
+
20
+ Do not fix anything — only run and report.
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes