etch-loop 0.5.2__tar.gz → 0.5.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: etch-loop
3
- Version: 0.5.2
3
+ Version: 0.5.3
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
@@ -0,0 +1,41 @@
1
+ # BREAK — breaker prompt
2
+
3
+ You are an adversarial code reviewer. Your job is to find anything that could go wrong.
4
+
5
+ ## Your mission
6
+
7
+ Scan the entire codebase with fresh eyes. Do not limit yourself to recent changes.
8
+
9
+ Look for:
10
+ - Edge cases and boundary conditions that are unhandled anywhere in the code
11
+ - Functions that assume valid input without checking
12
+ - Error paths that are silently swallowed or ignored
13
+ - Race conditions, off-by-one errors, null/empty/zero not guarded
14
+ - Anything that would cause unexpected behavior in production
15
+
16
+ Be adversarial — think like someone actively trying to make this code fail.
17
+
18
+ ## Rules
19
+
20
+ 1. DO NOT edit any files — read only
21
+ **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
22
+ 2. Report your findings clearly, one per line
23
+ 3. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
24
+ `<etch_summary>2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67</etch_summary>`
25
+ `<etch_summary>no issues found — code looks solid</etch_summary>`
26
+ **IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you read or edit.**
27
+ 4. End with EXACTLY one of these on its own line:
28
+ `ETCH_ISSUES_FOUND`
29
+ `ETCH_ALL_CLEAR`
30
+
31
+ ## Scope
32
+
33
+ - `loop.py`: The no_git/no_commit branch logic at line 181 (`if no_git or changed`) can reach the commit block even when `changed` is False if `no_git=True`, silently skipping the `git.has_changes()` call entirely. The `last_breaker_signal` fall-through at line 174 (fixer sees "no changes" but breaker had prior issues) proceeds to breaker without committing — the iteration state machine has several interacting flags that can produce silent no-ops.
34
+
35
+ - `agent.py`: The `stderr_reader` thread is joined with a 10-second timeout but its aliveness is never checked; a hung stderr drain can silently drop error output. If `process.kill()` is called after the stdout reader times out, `process.wait()` is called but `stderr_reader` may still be running, causing a race on `stderr_lines`.
36
+
37
+ - `signals.py` / `extract_commit_message`: The heuristic line-picker can return a token string (e.g. `ETCH_ISSUES_FOUND`) as a commit message if the token appears after other text rather than on its own line, since `parse()` requires exact-line match but `extract_commit_message` does not exclude token lines.
38
+
39
+ - `git.py` / `has_changes`: Exit code 128 (no commits yet) falls through to `git status --porcelain`, which correctly handles new repos, but `git diff --quiet HEAD` on an empty repo also silently ignores any staged changes — newly staged files in a repo with no HEAD commit would still register as changes, so the two-step detection logic is correct but fragile.
40
+
41
+ - `prompt.py`: `load_break` and `load_scan` deduplicate the cwd candidate only when `path` is already cwd — if `path.parent == cwd`, both the sibling candidate and the cwd fallback point to the same file, causing it to be read twice in the loop (harmless but surprising). More critically, `load_run` returns `None` for an empty `RUN.md` rather than raising, silently skipping the runner phase with no feedback.
@@ -0,0 +1,40 @@
1
+ # ETCH — fixer prompt
2
+
3
+ You are a surgical code reviewer focused on edge cases and robustness.
4
+
5
+ ## Your mission
6
+
7
+ Scan the codebase for:
8
+ - Unhandled edge cases and boundary conditions
9
+ - Missing null/None/empty checks
10
+ - Unhandled exceptions and error paths
11
+ - Off-by-one errors
12
+ - Race conditions or unsafe concurrent access
13
+ - Missing input validation at system boundaries
14
+
15
+ ## Rules
16
+
17
+ 0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
18
+ 1. Fix only what you find — do not refactor, rename, or reorganize
19
+ 2. Do not add comments explaining what you fixed
20
+ 3. If you find nothing, make no changes
21
+
22
+ ## Scope
23
+
24
+ - `loop.py`: The no_git/no_commit branch logic at line 181 (`if no_git or changed`) can reach the commit block even when `changed` is False if `no_git=True`, silently skipping the `git.has_changes()` call entirely. The `last_breaker_signal` fall-through at line 174 (fixer sees "no changes" but breaker had prior issues) proceeds to breaker without committing — the iteration state machine has several interacting flags that can produce silent no-ops.
25
+
26
+ - `agent.py`: The `stderr_reader` thread is joined with a 10-second timeout but its aliveness is never checked; a hung stderr drain can silently drop error output. If `process.kill()` is called after the stdout reader times out, `process.wait()` is called but `stderr_reader` may still be running, causing a race on `stderr_lines`.
27
+
28
+ - `signals.py` / `extract_commit_message`: The heuristic line-picker can return a token string (e.g. `ETCH_ISSUES_FOUND`) as a commit message if the token appears after other text rather than on its own line, since `parse()` requires exact-line match but `extract_commit_message` does not exclude token lines.
29
+
30
+ - `git.py` / `has_changes`: Exit code 128 (no commits yet) falls through to `git status --porcelain`, which correctly handles new repos, but `git diff --quiet HEAD` on an empty repo also silently ignores any staged changes — newly staged files in a repo with no HEAD commit would still register as changes, so the two-step detection logic is correct but fragile.
31
+
32
+ - `prompt.py`: `load_break` and `load_scan` deduplicate the cwd candidate only when `path` is already cwd — if `path.parent == cwd`, both the sibling candidate and the cwd fallback point to the same file, causing it to be read twice in the loop (harmless but surprising). More critically, `load_run` returns `None` for an empty `RUN.md` rather than raising, silently skipping the runner phase with no feedback.
33
+
34
+ ## Terminal output (required)
35
+
36
+ After making changes (or deciding there is nothing to fix), write your summary in this exact format — it appears in the terminal and is used as the commit message:
37
+ `<etch_summary>fixed 3 issues — null guard in auth.py, bounds check in parser.py, timeout in agent.py</etch_summary>`
38
+ `<etch_summary>nothing to fix — all reported issues were already handled</etch_summary>`
39
+
40
+ **IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you edit or create.**
@@ -0,0 +1,36 @@
1
+ # RUN — test writer and build validator
2
+
3
+ You are a test engineer. The fixer has just made changes. Your job is to write tests for what was changed, then run the full suite to confirm everything works.
4
+
5
+ ## Your mission
6
+
7
+ 1. **Write or update tests** — look at what the fixer changed and write targeted tests covering:
8
+ - The specific edge cases that were fixed
9
+ - Boundary conditions around the changed code
10
+ - Any regression paths that could break silently
11
+ Write tests in the project's existing test style and location.
12
+
13
+ 2. **Run the test suite** — run the full build and test commands to confirm everything passes.
14
+
15
+ ## Build and test commands
16
+
17
+ - `python -m pytest`
18
+
19
+ ## Rules
20
+
21
+ 0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
22
+ 1. You MAY edit test files — that is your job
23
+ 2. Do NOT touch production code — only tests
24
+ 3. If tests fail because of flawed test logic, fix the test and re-run before reporting
25
+ 4. **After tests pass, clean up everything you created during this session:**
26
+ - Delete every test file you wrote
27
+ - Delete any `__pycache__` directories inside the test directory
28
+ - If you created the test directory itself, remove it entirely (e.g. `rm -rf tests/`)
29
+ - Leave no temporary files or empty directories behind
30
+ 5. When done, write your summary in this exact format — it appears directly in the terminal:
31
+ `<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
32
+ `<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
33
+ **IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you edit or create.**
34
+ 6. End with EXACTLY one of these on its own line:
35
+ `ETCH_ALL_CLEAR` — if all tests pass
36
+ `ETCH_ISSUES_FOUND` — if tests reveal a bug in production code
@@ -0,0 +1,45 @@
1
+ # SCAN — scanner prompt
2
+
3
+ You are a code analyst. Your job is to find genuine bugs before the fixer runs.
4
+
5
+ ## Your mission
6
+
7
+ Read the codebase and produce a precise, actionable list of real issues:
8
+ - Unhandled edge cases and boundary conditions
9
+ - Missing null/None/empty checks that will cause crashes or wrong results
10
+ - Unhandled exceptions and error paths
11
+ - Off-by-one errors
12
+ - Race conditions or unsafe concurrent access
13
+ - Missing input validation at system boundaries
14
+
15
+ For each issue, include the file path, line number (if known), and a one-line description of what will go wrong.
16
+
17
+ ## Rules
18
+
19
+ 1. DO NOT edit any files — read only
20
+ **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
21
+ 2. Only report issues you are confident are genuine bugs — not observations, not style, not "could be cleaner"
22
+ 3. If something is already handled correctly, do NOT report it — even if the handling is unusual
23
+ 4. If you are unsure whether something is a bug, leave it out
24
+ 5. List each confirmed issue on its own line, e.g.:
25
+ - src/auth.py:42 — crashes with empty token string (no guard)
26
+ - src/api.js:108 — unhandled promise rejection will silently fail
27
+ 6. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
28
+ `<etch_summary>3 bugs found — null deref in auth.py:42, off-by-one in parser.py:88</etch_summary>`
29
+ `<etch_summary>no confirmed bugs found</etch_summary>`
30
+ **IMPORTANT: write `<etch_summary>` ONLY in your text response — never inside any file you read or edit.**
31
+ 7. End with EXACTLY one of these on its own line:
32
+ `ETCH_ISSUES_FOUND`
33
+ `ETCH_ALL_CLEAR`
34
+
35
+ ## Scope
36
+
37
+ - `loop.py`: The no_git/no_commit branch logic at line 181 (`if no_git or changed`) can reach the commit block even when `changed` is False if `no_git=True`, silently skipping the `git.has_changes()` call entirely. The `last_breaker_signal` fall-through at line 174 (fixer sees "no changes" but breaker had prior issues) proceeds to breaker without committing — the iteration state machine has several interacting flags that can produce silent no-ops.
38
+
39
+ - `agent.py`: The `stderr_reader` thread is joined with a 10-second timeout but its aliveness is never checked; a hung stderr drain can silently drop error output. If `process.kill()` is called after the stdout reader times out, `process.wait()` is called but `stderr_reader` may still be running, causing a race on `stderr_lines`.
40
+
41
+ - `signals.py` / `extract_commit_message`: The heuristic line-picker can return a token string (e.g. `ETCH_ISSUES_FOUND`) as a commit message if the token appears after other text rather than on its own line, since `parse()` requires exact-line match but `extract_commit_message` does not exclude token lines.
42
+
43
+ - `git.py` / `has_changes`: Exit code 128 (no commits yet) falls through to `git status --porcelain`, which correctly handles new repos, but `git diff --quiet HEAD` on an empty repo also silently ignores any staged changes — newly staged files in a repo with no HEAD commit would still register as changes, so the two-step detection logic is correct but fragile.
44
+
45
+ - `prompt.py`: `load_break` and `load_scan` deduplicate the cwd candidate only when `path` is already cwd — if `path.parent == cwd`, both the sibling candidate and the cwd fallback point to the same file, causing it to be read twice in the loop (harmless but surprising). More critically, `load_run` returns `None` for an empty `RUN.md` rather than raising, silently skipping the runner phase with no feedback.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "etch-loop"
3
- version = "0.5.2"
3
+ version = "0.5.3"
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.3"
@@ -68,10 +68,18 @@ def run(
68
68
  if stdin_writer.is_alive():
69
69
  process.kill()
70
70
  process.wait()
71
+ try:
72
+ process.stdin.close()
73
+ except OSError:
74
+ pass
71
75
  raise AgentError("Timed out writing prompt to claude stdin")
72
76
  if stdin_exc:
73
77
  process.kill()
74
78
  process.wait()
79
+ try:
80
+ process.stdin.close()
81
+ except OSError:
82
+ pass
75
83
  raise AgentError(f"Failed to write prompt to claude stdin: {stdin_exc[0]}") from stdin_exc[0]
76
84
 
77
85
  output_lines: list[str] = []
@@ -89,7 +97,8 @@ def run(
89
97
 
90
98
  def read_stderr() -> None:
91
99
  for line in process.stderr:
92
- stderr_lines.append(line)
100
+ with lock:
101
+ stderr_lines.append(line)
93
102
 
94
103
  reader = threading.Thread(target=read_stdout, daemon=True)
95
104
  stderr_reader = threading.Thread(target=read_stderr, daemon=True)
@@ -99,10 +108,12 @@ def run(
99
108
  if reader.is_alive():
100
109
  process.kill()
101
110
  process.wait()
111
+ stderr_reader.join(timeout=10)
112
+ if stderr_reader.is_alive():
113
+ with lock:
114
+ stderr_lines.append("[stderr reader timed out]")
102
115
  raise AgentError("claude subprocess timed out (output reader still running)")
103
116
 
104
- stderr_reader.join(timeout=10)
105
-
106
117
  try:
107
118
  process.wait(timeout=10)
108
119
  except subprocess.TimeoutExpired:
@@ -110,7 +121,13 @@ def run(
110
121
  process.wait()
111
122
  raise AgentError("claude subprocess timed out waiting for exit")
112
123
 
113
- stderr_output = "".join(stderr_lines).strip()
124
+ stderr_reader.join(timeout=10)
125
+ if stderr_reader.is_alive():
126
+ with lock:
127
+ stderr_lines.append("[stderr reader timed out]")
128
+
129
+ with lock:
130
+ stderr_output = "".join(stderr_lines).strip()
114
131
 
115
132
  if process.returncode != 0:
116
133
  detail = stderr_output or "(no stderr)"
@@ -173,6 +173,7 @@ For each issue, include the file path, line number (if known), and a one-line de
173
173
  ## Rules
174
174
 
175
175
  1. DO NOT edit any files — read only
176
+ **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
176
177
  2. Only report issues you are confident are genuine bugs — not observations, not style, not "could be cleaner"
177
178
  3. If something is already handled correctly, do NOT report it — even if the handling is unusual
178
179
  4. If you are unsure whether something is a bug, leave it out
@@ -213,6 +214,7 @@ Scan the codebase for:
213
214
 
214
215
  ## Rules
215
216
 
217
+ 0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
216
218
  1. Fix only what you find — do not refactor, rename, or reorganize
217
219
  2. Do not add comments explaining what you fixed
218
220
  3. If you find nothing, make no changes
@@ -255,6 +257,7 @@ Be adversarial — think like someone actively trying to make this code fail.
255
257
  ## Rules
256
258
 
257
259
  1. DO NOT edit any files — read only
260
+ **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
258
261
  2. Report your findings clearly, one per line
259
262
  3. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
260
263
  `<etch_summary>2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67</etch_summary>`
@@ -300,10 +303,15 @@ You are a test engineer. The fixer has just made changes. Your job is to write t
300
303
 
301
304
  ## Rules
302
305
 
306
+ 0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
303
307
  1. You MAY edit test files — that is your job
304
308
  2. Do NOT touch production code — only tests
305
309
  3. If tests fail because of flawed test logic, fix the test and re-run before reporting
306
- 4. **After tests pass, delete every test file you created during this session** — leave no temporary test files behind
310
+ 4. **After tests pass, clean up everything you created during this session:**
311
+ - Delete every test file you wrote
312
+ - Delete any `__pycache__` directories inside the test directory
313
+ - If you created the test directory itself, remove it entirely (e.g. `rm -rf tests/`)
314
+ - Leave no temporary files or empty directories behind
307
315
  5. When done, write your summary in this exact format — it appears directly in the terminal:
308
316
  `<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
309
317
  `<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
@@ -324,7 +332,9 @@ def _detect_run_commands(root: Path) -> list[str]:
324
332
  if (root / "package.json").exists():
325
333
  try:
326
334
  pkg = json.loads((root / "package.json").read_text(encoding="utf-8"))
327
- scripts = pkg.get("scripts", {})
335
+ scripts = pkg.get("scripts") or {}
336
+ if not isinstance(scripts, dict):
337
+ scripts = {}
328
338
  if "build" in scripts:
329
339
  commands.append("npm run build")
330
340
  if "test" in scripts:
@@ -379,14 +389,14 @@ def _list_files(root: Path) -> list[str]:
379
389
  )
380
390
  if result.returncode == 0 and result.stdout.strip():
381
391
  return result.stdout.strip().splitlines()
382
- except (subprocess.TimeoutExpired, FileNotFoundError):
392
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
383
393
  pass
384
394
 
385
395
  # Fallback: walk filesystem
386
396
  files = []
387
397
  try:
388
398
  for p in root.rglob("*"):
389
- if p.is_file() and not any(part in _SKIP_DIRS for part in p.parts):
399
+ if p.is_file() and not any(part in _SKIP_DIRS for part in p.relative_to(root).parts):
390
400
  try:
391
401
  files.append(str(p.relative_to(root)))
392
402
  except ValueError:
@@ -287,8 +287,10 @@ class EtchDisplay:
287
287
  )
288
288
 
289
289
  def _refresh(self) -> None:
290
- if self._live is not None:
291
- self._live.update(self._render())
290
+ rendered = self._render()
291
+ with self._lock:
292
+ if self._live is not None:
293
+ self._live.update(rendered)
292
294
 
293
295
  # ── Ticker thread ─────────────────────────────────────────────────────────
294
296
 
@@ -303,6 +305,9 @@ class EtchDisplay:
303
305
  self._ticker_stop.set()
304
306
  if self._ticker_thread is not None:
305
307
  self._ticker_thread.join(timeout=1.0)
308
+ if self._ticker_thread.is_alive():
309
+ with self._lock:
310
+ self._live = None
306
311
  self._ticker_thread = None
307
312
 
308
313
  def _ticker_loop(self) -> None:
@@ -401,8 +406,10 @@ class InitDisplay:
401
406
  )
402
407
 
403
408
  def _refresh(self) -> None:
404
- if self._live is not None:
405
- self._live.update(self._render())
409
+ rendered = self._render()
410
+ with self._lock:
411
+ if self._live is not None:
412
+ self._live.update(rendered)
406
413
 
407
414
  def _start_ticker(self) -> None:
408
415
  self._ticker_stop.clear()
@@ -413,6 +420,9 @@ class InitDisplay:
413
420
  self._ticker_stop.set()
414
421
  if self._ticker_thread is not None:
415
422
  self._ticker_thread.join(timeout=1.0)
423
+ if self._ticker_thread.is_alive():
424
+ with self._lock:
425
+ self._live = None
416
426
  self._ticker_thread = None
417
427
 
418
428
  def _ticker_loop(self) -> None:
@@ -478,6 +488,8 @@ def print_summary(stats: dict[str, Any]) -> None:
478
488
  title = f"[{RED}]x agent error[/{RED}]"
479
489
  elif reason == "git_error":
480
490
  title = f"[{RED}]x git error[/{RED}]"
491
+ elif reason == "stalled":
492
+ title = f"[{AMBER}]- stalled (fixer found nothing)[/{AMBER}]"
481
493
  else:
482
494
  title = f"[{FG}]done[/{FG}]"
483
495
 
@@ -56,6 +56,28 @@ def has_changes() -> bool:
56
56
  return bool(status.stdout.strip())
57
57
 
58
58
 
59
+ def changed_files(since_commits: int = 1) -> list[str]:
60
+ """Return files changed in the last N commits.
61
+
62
+ Used to focus the breaker on files the fixer actually touched,
63
+ rather than the entire codebase.
64
+
65
+ Returns an empty list if git is unavailable or no commits exist.
66
+ """
67
+ try:
68
+ result = subprocess.run(
69
+ ["git", "diff", "--name-only", f"HEAD~{since_commits}", "HEAD"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=10,
73
+ )
74
+ if result.returncode == 0 and result.stdout.strip():
75
+ return result.stdout.strip().splitlines()
76
+ except (OSError, FileNotFoundError, subprocess.TimeoutExpired):
77
+ pass
78
+ return []
79
+
80
+
59
81
  def commit(message: str, paths: list[str] | None = None) -> None:
60
82
  """Stage all changes and create a commit.
61
83
 
@@ -69,7 +91,7 @@ def commit(message: str, paths: list[str] | None = None) -> None:
69
91
  raise GitError("Commit message must not be empty.")
70
92
 
71
93
  # Stage all changes (or specific paths)
72
- add_cmd = ["git", "add"] + (paths if paths is not None else ["--all"])
94
+ add_cmd = ["git", "add"] + (paths if paths else ["--all"])
73
95
  try:
74
96
  add_result = subprocess.run(
75
97
  add_cmd,
@@ -157,6 +157,7 @@ def run(
157
157
  fixer_duration = time.monotonic() - fixer_start
158
158
 
159
159
  # ── Check for changes (skipped when no_git) ───────────────────────
160
+ changed = False
160
161
  if not no_git:
161
162
  try:
162
163
  changed = git.has_changes()
@@ -171,11 +172,9 @@ def run(
171
172
  disp.finish_phase("fixer", status="no changes", detail="nothing to fix",
172
173
  duration=fixer_duration, success=True)
173
174
  iter_entry["fixer"] = {"status": "no changes", "detail": "nothing to fix"}
174
- if last_breaker_signal != "issues":
175
- stats["reason"] = "no_changes"
176
- iteration_log.append(iter_entry)
177
- break
178
- # Fall through to breaker
175
+ stats["reason"] = "stalled" if last_breaker_signal == "issues" else "no_changes"
176
+ iteration_log.append(iter_entry)
177
+ break
179
178
 
180
179
  # ── Commit ────────────────────────────────────────────────────────
181
180
  if no_git or changed:
@@ -196,9 +195,10 @@ def run(
196
195
  iteration_log.append(iter_entry)
197
196
  break
198
197
 
199
- disp.record_fix()
200
- stats["fixes"] += 1
201
- status_label = "changed" if (no_git or no_commit) else "committed"
198
+ if changed or (no_git and fixer_summary and not fixer_summary.lower().startswith("nothing")):
199
+ disp.record_fix()
200
+ stats["fixes"] += 1
201
+ status_label = "no-git" if no_git else ("changed" if no_commit else "committed")
202
202
  fixer_detail = fixer_summary or commit_msg
203
203
  disp.finish_phase("fixer", status=status_label, detail=fixer_detail,
204
204
  duration=fixer_duration, success=True)
@@ -207,8 +207,22 @@ def run(
207
207
  # ── Breaker phase ─────────────────────────────────────────────────
208
208
  disp.start_phase("breaker")
209
209
  breaker_start = time.monotonic()
210
+ # Focus the breaker only on files the fixer actually changed.
211
+ # This prevents the breaker from finding brand-new issues in
212
+ # untouched files, which causes the loop to thrash.
213
+ effective_break_text = break_text
214
+ if not no_git:
215
+ recent_files = git.changed_files(since_commits=1)
216
+ if recent_files:
217
+ files_list = "\n".join(f"- {f}" for f in recent_files)
218
+ effective_break_text = break_text + (
219
+ f"\n\n## Scope for this iteration\n\n"
220
+ f"The fixer just changed these files — review ONLY these:\n"
221
+ f"{files_list}\n\n"
222
+ f"Do not scan files that were not changed.\n"
223
+ )
210
224
  try:
211
- breaker_output = agent.run(break_text, verbose=verbose)
225
+ breaker_output = agent.run(effective_break_text, verbose=verbose)
212
226
  except AgentError as exc:
213
227
  disp.finish_phase("breaker", status="error", detail=str(exc),
214
228
  duration=time.monotonic() - breaker_start, success=False)
@@ -255,7 +269,7 @@ def run(
255
269
  stats["reason"] = "max_iterations"
256
270
 
257
271
  # ── Runner — final step, only when loop ended cleanly ─────────────────
258
- if run_text and stats["reason"] in ("clear", "no_changes"):
272
+ if run_text and stats["reason"] == "clear":
259
273
  disp.start_phase("runner")
260
274
  runner_start = time.monotonic()
261
275
  try:
@@ -25,7 +25,10 @@ def load(path: str | Path) -> str:
25
25
  if not p.is_file():
26
26
  raise PromptError(f"Prompt path is not a file: {p}")
27
27
 
28
- content = p.read_text(encoding="utf-8")
28
+ try:
29
+ content = p.read_text(encoding="utf-8")
30
+ except FileNotFoundError:
31
+ raise PromptError(f"Prompt file not found: {p}")
29
32
  if not content.strip():
30
33
  raise PromptError(f"Prompt file is empty: {p}")
31
34
 
@@ -66,7 +69,10 @@ def load_break(path: str | Path | None = None) -> str:
66
69
 
67
70
  for candidate in candidates:
68
71
  if candidate.exists() and candidate.is_file():
69
- content = candidate.read_text(encoding="utf-8")
72
+ try:
73
+ content = candidate.read_text(encoding="utf-8")
74
+ except FileNotFoundError:
75
+ continue
70
76
  if not content.strip():
71
77
  raise PromptError(f"BREAK.md is empty: {candidate}")
72
78
  return content
@@ -97,9 +103,12 @@ def load_run(path: str | Path | None = None) -> str | None:
97
103
 
98
104
  for candidate in candidates:
99
105
  if candidate.exists() and candidate.is_file():
100
- content = candidate.read_text(encoding="utf-8")
106
+ try:
107
+ content = candidate.read_text(encoding="utf-8")
108
+ except FileNotFoundError:
109
+ continue
101
110
  if not content.strip():
102
- return None
111
+ raise PromptError(f"RUN.md is empty: {candidate}")
103
112
  return content
104
113
 
105
114
  return None # Optional phase — no error if absent
@@ -126,7 +135,10 @@ def load_scan(path: str | Path | None = None) -> str:
126
135
 
127
136
  for candidate in candidates:
128
137
  if candidate.exists() and candidate.is_file():
129
- content = candidate.read_text(encoding="utf-8")
138
+ try:
139
+ content = candidate.read_text(encoding="utf-8")
140
+ except FileNotFoundError:
141
+ continue
130
142
  if not content.strip():
131
143
  raise PromptError(f"SCAN.md is empty: {candidate}")
132
144
  return content
@@ -25,7 +25,7 @@ def write(
25
25
  output_dir = output_dir or Path.cwd()
26
26
  reports_dir = output_dir / "etch-reports"
27
27
  reports_dir.mkdir(exist_ok=True)
28
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M")
28
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
29
29
  path = reports_dir / f"etch-report-{timestamp}.md"
30
30
 
31
31
  lines: list[str] = []
@@ -65,6 +65,9 @@ def extract_commit_message(output: str, fallback: str) -> str:
65
65
  stripped = line.strip().lstrip("-*•").strip().strip("`").strip()
66
66
  if not stripped or len(stripped) < 8:
67
67
  continue
68
+ bare = line.strip().strip("`").strip()
69
+ if bare == _TOKEN_CLEAR or bare == _TOKEN_ISSUES:
70
+ break
68
71
  if stripped.startswith("#"):
69
72
  continue
70
73
  if all(c in _PUNCTUATION_ONLY for c in stripped):
@@ -97,7 +100,7 @@ def extract_summary(output: str) -> str:
97
100
  return ""
98
101
  m = re.search(r"<etch_summary>(.*?)</etch_summary>", output, re.DOTALL)
99
102
  if m:
100
- return m.group(1).strip()
103
+ return " ".join(m.group(1).split())
101
104
  return ""
102
105
 
103
106
 
@@ -113,7 +116,7 @@ def extract_finding(output: str) -> str:
113
116
  lines_before: list[str] = []
114
117
  for line in output.splitlines():
115
118
  stripped = line.strip().strip("`").strip()
116
- if stripped in (_TOKEN_CLEAR, _TOKEN_ISSUES):
119
+ if stripped == _TOKEN_CLEAR or stripped == _TOKEN_ISSUES:
117
120
  break
118
121
  lines_before.append(line)
119
122
 
@@ -18,6 +18,7 @@ Be adversarial — think like someone actively trying to make this code fail.
18
18
  ## Rules
19
19
 
20
20
  1. DO NOT edit any files — read only
21
+ **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
21
22
  2. Report your findings clearly, one per line
22
23
  3. Before the signal token, write your summary in this exact format — it appears directly in the terminal:
23
24
  `<etch_summary>2 issues — unguarded empty list in sorter.py:14, exception swallowed in loader.py:67</etch_summary>`
@@ -14,6 +14,7 @@ Scan the codebase for:
14
14
 
15
15
  ## Rules
16
16
 
17
+ 0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
17
18
  1. Fix only what you find — do not refactor, rename, or reorganize
18
19
  2. Do not add comments explaining what you fixed
19
20
  3. If you find nothing, make no changes
@@ -18,10 +18,15 @@ You are a test engineer. The fixer has just made changes. Your job is to write t
18
18
 
19
19
  ## Rules
20
20
 
21
+ 0. **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
21
22
  1. You MAY edit test files — that is your job
22
23
  2. Do NOT touch production code — only tests
23
24
  3. If tests fail because of flawed test logic, fix the test and re-run before reporting
24
- 4. **After tests pass, delete every test file you created during this session** — leave no temporary test files behind
25
+ 4. **After tests pass, clean up everything you created during this session:**
26
+ - Delete every test file you wrote
27
+ - Delete any `__pycache__` directories inside the test directory
28
+ - If you created the test directory itself, remove it entirely (e.g. `rm -rf tests/`)
29
+ - Leave no temporary files or empty directories behind
25
30
  5. When done, write your summary in this exact format — it appears directly in the terminal:
26
31
  `<etch_summary>wrote 4 tests, all 51 passed</etch_summary>`
27
32
  `<etch_summary>2 tests failed — TypeError in test_auth.py:38, production bug in token.py:12</etch_summary>`
@@ -17,6 +17,7 @@ For each issue, include the file path, line number (if known), and a one-line de
17
17
  ## Rules
18
18
 
19
19
  1. DO NOT edit any files — read only
20
+ **IGNORE the `etch-loop/` directory entirely** — it contains etch tool metadata, not production code
20
21
  2. Only report issues you are confident are genuine bugs — not observations, not style, not "could be cleaner"
21
22
  3. If something is already handled correctly, do NOT report it — even if the handling is unusual
22
23
  4. If you are unsure whether something is a bug, leave it out
@@ -34,7 +34,7 @@ wheels = [
34
34
 
35
35
  [[package]]
36
36
  name = "etch-loop"
37
- version = "0.4.9"
37
+ version = "0.5.2"
38
38
  source = { editable = "." }
39
39
  dependencies = [
40
40
  { name = "rich" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.2"
File without changes
File without changes